fix(formats): normalize Responses additional tools for Chat

This commit is contained in:
ZheFox
2026-08-28 23:03:56 +08:00
parent 83098f98b6
commit 5bcdcca784
3 changed files with 292 additions and 10 deletions
@@ -102,6 +102,59 @@ fn builds_openai_chat_cross_format_request_body_from_openai_responses_source() {
assert_eq!(provider_request_body["messages"][0]["content"], "hello");
}
#[test]
fn maps_openai_responses_additional_tools_without_message_name() {
let body_json = json!({
"model": "gpt-5",
"input": [
{
"type": "additional_tools",
"role": "developer",
"tools": [{
"type": "function",
"name": "get_weather",
"description": "Get the weather",
"parameters": {
"type": "object",
"properties": {}
}
}]
},
{
"role": "user",
"content": "What is the weather?"
}
]
});
let provider_request_body = build_cross_format_openai_responses_request_body(
&body_json,
"gpt-5-upstream",
"openai:responses",
"openai:chat",
false,
false,
"openai",
None,
None,
&http::HeaderMap::new(),
false,
)
.expect("Responses additional tools should map to a Chat request body");
assert_eq!(
provider_request_body["messages"].as_array().map(Vec::len),
Some(1)
);
assert_eq!(provider_request_body["messages"][0]["role"], "user");
assert!(provider_request_body["messages"][0].get("name").is_none());
assert_eq!(provider_request_body["tools"][0]["type"], "function");
assert_eq!(
provider_request_body["tools"][0]["function"]["name"],
"get_weather"
);
}
#[test]
fn local_openai_responses_wrapper_preserves_body_order_after_edits() {
let body_json: Value = serde_json::from_str(
@@ -120,12 +120,15 @@ pub fn convert_request_pure_with_context(
ctx: &FormatContext,
) -> Result<Converted<Value>, FormatError> {
let pure_ctx = ctx.without_runtime_request_edits();
let request = parse_request(source_format, body, &pure_ctx)?;
validate_openai_responses_target_contract(target_format, body)?;
let source = parse_format(source_format)?;
let target = parse_format(target_format)?;
let normalized_body = normalize_openai_responses_to_chat_body(source, target, body)?;
let request = parse_request(source_format, &normalized_body, &pure_ctx)?;
validate_openai_responses_target_contract(target_format, &normalized_body)?;
validate_request_conversion(
source_format,
target_format,
body,
&normalized_body,
&request,
ctx.mapped_model.as_deref(),
)?;
@@ -158,12 +161,13 @@ pub fn convert_request(
None
};
let body = expanded_body.as_ref().unwrap_or(body);
validate_openai_responses_target_contract(target_format, body)?;
let mut request = parse_request(source_format, body, ctx)?;
let normalized_body = normalize_openai_responses_to_chat_body(source, target, body)?;
validate_openai_responses_target_contract(target_format, &normalized_body)?;
let mut request = parse_request(source_format, &normalized_body, ctx)?;
validate_runtime_request_conversion(
source,
target,
body,
&normalized_body,
&request,
ctx.mapped_model.as_deref(),
)?;
@@ -177,6 +181,81 @@ pub fn convert_request(
emit_request_inner(target_format, &request, ctx)
}
fn normalize_openai_responses_to_chat_body(
source: FormatId,
target: FormatId,
body: &Value,
) -> Result<Value, FormatError> {
if !matches!(
source,
FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact
) || target != FormatId::OpenAiChat
{
return Ok(body.clone());
}
let Some(body_object) = body.as_object() else {
return Ok(body.clone());
};
let Some(input) = body_object.get("input").and_then(Value::as_array) else {
return Ok(body.clone());
};
let additional_tools_count = input
.iter()
.take_while(|item| is_openai_responses_additional_tools_item(item))
.count();
if additional_tools_count == 0 {
return Ok(body.clone());
}
if body_object
.get("tools")
.is_some_and(|tools| !tools.is_array())
{
return Ok(body.clone());
}
let mut normalized = body.clone();
let normalized_object = normalized
.as_object_mut()
.expect("Responses request body object was checked above");
let normalized_input = normalized_object
.get_mut("input")
.and_then(Value::as_array_mut)
.expect("Responses request input array was checked above");
let additional_tools = normalized_input.drain(..additional_tools_count);
let mut tools = Vec::new();
for additional_tools in additional_tools {
tools.extend(
additional_tools["tools"]
.as_array()
.expect("additional_tools item was checked above")
.iter()
.cloned(),
);
}
if let Some(existing_tools) = normalized_object.get("tools").and_then(Value::as_array) {
tools.extend(existing_tools.iter().cloned());
}
normalized_object.insert("tools".to_string(), Value::Array(tools));
Ok(normalized)
}
fn is_openai_responses_additional_tools_item(value: &Value) -> bool {
let Some(object) = value.as_object() else {
return false;
};
object
.get("type")
.and_then(Value::as_str)
.is_some_and(|item_type| item_type.eq_ignore_ascii_case("additional_tools"))
&& object.get("role").and_then(Value::as_str) == Some("developer")
&& object.get("tools").is_some_and(Value::is_array)
&& object
.keys()
.all(|key| matches!(key.as_str(), "type" | "role" | "tools"))
}
fn validate_runtime_request_conversion(
source: FormatId,
target: FormatId,
@@ -1528,8 +1607,11 @@ fn validate_request_extension_namespace(
});
};
for key in object.keys() {
if request_extension_key_is_cross_format_safe(source, target, location, namespace, key)
{
if openai_responses_custom_tool_key_is_cross_format_safe(
source, target, location, namespace, object, key,
) || request_extension_key_is_cross_format_safe(
source, target, location, namespace, key,
) {
continue;
}
return Err(FormatError::LossyConversionBlocked {
@@ -1544,6 +1626,29 @@ fn validate_request_extension_namespace(
Ok(())
}
fn openai_responses_custom_tool_key_is_cross_format_safe(
source: FormatId,
target: FormatId,
location: &str,
namespace: &str,
extension: &Map<String, Value>,
key: &str,
) -> bool {
matches!(
(source, target, location, namespace),
(
FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact,
FormatId::OpenAiChat,
"tools[]",
"openai_responses" | "openai_cli"
)
) && extension
.get("type")
.and_then(Value::as_str)
.is_some_and(|tool_type| tool_type.eq_ignore_ascii_case("custom"))
&& matches!(key, "type" | "name" | "description" | "format" | "custom")
}
fn request_extension_key_is_cross_format_safe(
source: FormatId,
target: FormatId,
@@ -2609,7 +2714,7 @@ fn validate_openai_responses_to_chat(
.unwrap_or("function")
.trim()
.to_ascii_lowercase();
if !matches!(tool_type.as_str(), "function" | "namespace") {
if !matches!(tool_type.as_str(), "function" | "custom" | "namespace") {
return Err(FormatError::LossyConversionBlocked {
source_format: FormatId::OpenAiResponses.as_str().to_string(),
target_format: FormatId::OpenAiChat.as_str().to_string(),
@@ -5175,6 +5280,130 @@ mod tests {
));
}
#[test]
fn openai_responses_additional_tools_prefix_maps_to_chat_tools() {
let body = json!({
"model": "gpt-5.6-sol",
"input": [
{
"type": "additional_tools",
"role": "developer",
"tools": [{
"type": "function",
"name": "lookup",
"description": "Look up a value",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]
},
"strict": true
}, {
"type": "custom",
"name": "shell_command",
"description": "Run a shell command",
"format": {"type": "text"}
}]
},
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]}
],
"tools": [{
"type": "function",
"name": "existing",
"parameters": {"type": "object"}
}]
});
for converted in [
convert_request_pure("openai:responses", "openai:chat", &body)
.expect("pure conversion should map additional tools")
.value,
convert_request(
"openai:responses",
"openai:chat",
&body,
&FormatContext::default(),
)
.expect("runtime conversion should map additional tools"),
] {
assert_eq!(converted["tools"][0]["type"], "function");
assert_eq!(converted["tools"][0]["function"]["name"], "lookup");
assert_eq!(converted["tools"][0]["function"]["strict"], true);
assert_eq!(converted["tools"][1]["type"], "custom");
assert_eq!(converted["tools"][1]["custom"]["name"], "shell_command");
assert_eq!(converted["tools"][2]["function"]["name"], "existing");
assert_eq!(converted["messages"][0]["role"], "user");
assert_eq!(converted["messages"].as_array().map(Vec::len), Some(1));
}
}
#[test]
fn openai_responses_additional_tools_prefix_rejects_unmapped_tool() {
let body = json!({
"model": "gpt-5.6-sol",
"input": [{
"type": "additional_tools",
"role": "developer",
"tools": [{"type": "tool_search", "execution": "client"}]
}]
});
let error = convert_request_pure("openai:responses", "openai:chat", &body)
.expect_err("Chat cannot represent client tool_search");
assert!(matches!(
error,
super::FormatError::LossyConversionBlocked { ref field, .. }
if field == "tools"
));
}
#[test]
fn openai_responses_additional_tools_prefix_rejects_unknown_fields() {
let body = json!({
"model": "gpt-5.6-sol",
"input": [{
"type": "additional_tools",
"role": "developer",
"tools": [],
"future_field": true
}]
});
let error = convert_request_pure("openai:responses", "openai:chat", &body)
.expect_err("unknown additional_tools fields must not be dropped");
assert!(matches!(
error,
super::FormatError::LossyConversionBlocked { ref field, .. }
if field == "input[0]"
));
}
#[test]
fn openai_responses_additional_tools_is_only_consumed_as_a_leading_prefix() {
let body = json!({
"model": "gpt-5.6-sol",
"input": [
{"type": "message", "role": "user", "content": "hello"},
{
"type": "additional_tools",
"role": "developer",
"tools": [{"type": "function", "name": "lookup", "parameters": {}}]
}
]
});
let error = convert_request_pure("openai:responses", "openai:chat", &body)
.expect_err("additional_tools after conversation history must remain unsupported");
assert!(matches!(
error,
super::FormatError::LossyConversionBlocked { ref field, .. }
if field == "input[1]"
));
}
#[test]
fn runtime_openai_responses_cross_format_rejects_unknown_content_block() {
let body = json!({
@@ -1783,7 +1783,7 @@ mod tests {
let converted = build_standard_request_body(
&request,
"claude:messages",
"gemini-2.5-pro",
"gemini-3-flash-preview",
"google",
"gemini:generate_content",
"/v1/messages",