mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 04:30:20 +08:00
fix(formats): normalize Responses additional tools for Chat
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user