fix(provider): normalize OpenAI chat tool history for Claude messages

This commit is contained in:
zhefox
2026-05-23 02:45:04 +08:00
parent e5ce2ac7a4
commit bd1e155332
2 changed files with 194 additions and 9 deletions
@@ -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>,