mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-13 14:40:20 +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);
|
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 {
|
fn codex_default_body_rules() -> Value {
|
||||||
json!([
|
json!([
|
||||||
{"action":"drop","path":"max_output_tokens"},
|
{"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";
|
pub(crate) const OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE: &str = "openai_cli";
|
||||||
const AETHER_EXTENSION_NAMESPACE: &str = "aether";
|
const AETHER_EXTENSION_NAMESPACE: &str = "aether";
|
||||||
const CLAUDE_TOOL_RESULT_SOURCE_MARKER: &str = "claude_tool_result";
|
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]";
|
const OPENAI_CHAT_TOOL_ERROR_PREFIX: &str = "[tool error]";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[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,
|
"assistant" => CanonicalRole::Assistant,
|
||||||
"system" => CanonicalRole::System,
|
"system" => CanonicalRole::System,
|
||||||
"developer" => CanonicalRole::Developer,
|
"developer" => CanonicalRole::Developer,
|
||||||
"tool" => CanonicalRole::Tool,
|
"tool" | "function" => CanonicalRole::Tool,
|
||||||
_ => CanonicalRole::Unknown,
|
_ => CanonicalRole::Unknown,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1329,10 +1330,12 @@ pub(crate) fn openai_message_content_blocks(
|
|||||||
blocks.splice(0..0, reasoning_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) {
|
if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) {
|
||||||
for tool_call in tool_calls {
|
for tool_call in tool_calls {
|
||||||
let tool_call = tool_call.as_object()?;
|
let tool_call = tool_call.as_object()?;
|
||||||
let function = tool_call.get("function").and_then(Value::as_object)?;
|
let function = tool_call.get("function").and_then(Value::as_object)?;
|
||||||
|
saw_tool_calls = true;
|
||||||
blocks.push(CanonicalContentBlock::ToolUse {
|
blocks.push(CanonicalContentBlock::ToolUse {
|
||||||
id: tool_call
|
id: tool_call
|
||||||
.get("id")
|
.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 {
|
if role == CanonicalRole::Tool {
|
||||||
let text = openai_content_text(message.get("content"));
|
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 {
|
blocks.push(CanonicalContentBlock::ToolResult {
|
||||||
tool_use_id: message
|
tool_use_id: message
|
||||||
.get("tool_call_id")
|
.get("tool_call_id")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
|
.or_else(|| message.get("name").and_then(Value::as_str))
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string(),
|
.to_string(),
|
||||||
name: None,
|
name: None,
|
||||||
@@ -1374,7 +1403,7 @@ pub(crate) fn openai_message_content_blocks(
|
|||||||
text
|
text
|
||||||
}),
|
}),
|
||||||
is_error: false,
|
is_error: false,
|
||||||
extensions: openai_extensions(message, &["role", "content", "tool_call_id"]),
|
extensions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Some(blocks)
|
Some(blocks)
|
||||||
@@ -3798,7 +3827,10 @@ pub(crate) fn canonical_block_to_claude(
|
|||||||
} => {
|
} => {
|
||||||
let mut out = Map::new();
|
let mut out = Map::new();
|
||||||
out.insert("type".to_string(), Value::String("tool_use".to_string()));
|
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("name".to_string(), Value::String(name.clone()));
|
||||||
out.insert("input".to_string(), input.clone());
|
out.insert("input".to_string(), input.clone());
|
||||||
out.extend(namespace_extension_object(extensions, "claude", &out));
|
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("type".to_string(), Value::String("tool_result".to_string()));
|
||||||
out.insert(
|
out.insert(
|
||||||
"tool_use_id".to_string(),
|
"tool_use_id".to_string(),
|
||||||
Value::String(tool_use_id.clone()),
|
Value::String(claude_compatible_tool_use_id(tool_use_id)),
|
||||||
);
|
);
|
||||||
out.insert(
|
out.insert(
|
||||||
"content".to_string(),
|
"content".to_string(),
|
||||||
@@ -3824,6 +3856,7 @@ pub(crate) fn canonical_block_to_claude(
|
|||||||
output.as_ref(),
|
output.as_ref(),
|
||||||
content_text.as_deref(),
|
content_text.as_deref(),
|
||||||
role,
|
role,
|
||||||
|
extensions,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
out.insert("is_error".to_string(), Value::Bool(*is_error));
|
out.insert("is_error".to_string(), Value::Bool(*is_error));
|
||||||
@@ -3838,6 +3871,7 @@ fn canonical_tool_result_content_to_claude(
|
|||||||
output: Option<&Value>,
|
output: Option<&Value>,
|
||||||
content_text: Option<&str>,
|
content_text: Option<&str>,
|
||||||
role: &CanonicalRole,
|
role: &CanonicalRole,
|
||||||
|
extensions: &BTreeMap<String, Value>,
|
||||||
) -> Value {
|
) -> Value {
|
||||||
if matches!(role, CanonicalRole::Assistant) {
|
if matches!(role, CanonicalRole::Assistant) {
|
||||||
return output
|
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()));
|
.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 {
|
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) => {
|
Some(Value::Array(parts)) if claude_tool_result_content_blocks_are_wire_safe(parts) => {
|
||||||
Value::Array(parts.clone())
|
Value::Array(parts.clone())
|
||||||
}
|
}
|
||||||
|
Some(Value::Null) => Value::String(non_empty_tool_result_text("")),
|
||||||
Some(value) => serde_json::to_string(value)
|
Some(value) => serde_json::to_string(value)
|
||||||
.map(Value::String)
|
.map(|text| Value::String(non_empty_tool_result_text(&text)))
|
||||||
.unwrap_or_else(|_| Value::String(content_text.unwrap_or_default().to_string())),
|
.unwrap_or_else(|_| {
|
||||||
None => Value::String(content_text.unwrap_or_default().to_string()),
|
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(
|
out.insert(
|
||||||
"input_schema".to_string(),
|
"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));
|
out.extend(namespace_extension_object(&tool.extensions, "claude", &out));
|
||||||
Value::Object(out)
|
Value::Object(out)
|
||||||
@@ -4004,6 +4077,23 @@ pub(crate) fn canonical_tools_to_claude(canonical: &CanonicalRequest) -> Vec<Val
|
|||||||
tools
|
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(
|
pub(crate) fn canonical_tool_choice_to_claude(
|
||||||
choice: Option<&CanonicalToolChoice>,
|
choice: Option<&CanonicalToolChoice>,
|
||||||
parallel_tool_calls: Option<bool>,
|
parallel_tool_calls: Option<bool>,
|
||||||
|
|||||||
Reference in New Issue
Block a user