fix(kiro): 修复 Kiro WebSearch MCP 调用 (#344)

* fix(kiro): 接入 Kiro MCP web_search 工具调用

* fix(kiro): 对齐 Kiro IDE MCP 鉴权与 profileArn 头部

* fix(responses): 保留 Responses 嵌套与自定义工具参数

* test(ci): 通过 Rust Action 三项检查
This commit is contained in:
Entropy.Xu
2026-04-27 12:20:26 +08:00
committed by GitHub
parent 488bd08f04
commit 6e1eaf8aec
11 changed files with 1917 additions and 11 deletions

View File

@@ -2807,7 +2807,29 @@ pub(crate) fn openai_responses_tools_to_canonical(
.unwrap_or("function")
.trim()
.to_ascii_lowercase();
if tool_type == "function" && tool_object.get("function").is_none() {
if tool_type == "function" {
if let Some(function) = tool_object.get("function").and_then(Value::as_object) {
let name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mut extensions =
openai_responses_extensions(tool_object, &["type", "function"]);
let function_extensions =
openai_responses_extensions(function, &["name", "description", "parameters"]);
merge_tool_extensions(&mut extensions, function_extensions);
canonical.push(CanonicalToolDefinition {
name: name.to_string(),
description: function
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
parameters: function.get("parameters").cloned(),
extensions,
});
continue;
}
let name = tool_object
.get("name")
.and_then(Value::as_str)
@@ -2825,6 +2847,41 @@ pub(crate) fn openai_responses_tools_to_canonical(
&["type", "name", "description", "parameters"],
),
});
} else if tool_type == "custom" {
let custom = tool_object.get("custom").and_then(Value::as_object);
let name = tool_object
.get("name")
.and_then(Value::as_str)
.or_else(|| {
custom
.and_then(|value| value.get("name"))
.and_then(Value::as_str)
})
.map(str::trim)
.filter(|value| !value.is_empty())?;
let description = tool_object
.get("description")
.and_then(Value::as_str)
.or_else(|| {
custom
.and_then(|value| value.get("description"))
.and_then(Value::as_str)
})
.map(ToOwned::to_owned);
let parameters = tool_object
.get("parameters")
.or_else(|| custom.and_then(|value| value.get("parameters")))
.filter(|value| value.is_object())
.cloned();
canonical.push(CanonicalToolDefinition {
name: name.to_string(),
description,
parameters,
extensions: BTreeMap::from([(
OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(),
tool.clone(),
)]),
});
} else if tool_type.starts_with("web_search") {
canonical.push(CanonicalToolDefinition {
name: tool_type,
@@ -2840,6 +2897,19 @@ pub(crate) fn openai_responses_tools_to_canonical(
Some(canonical)
}
fn merge_tool_extensions(target: &mut BTreeMap<String, Value>, source: BTreeMap<String, Value>) {
for (namespace, value) in source {
match (target.get_mut(&namespace), value) {
(Some(Value::Object(target)), Value::Object(source)) => {
target.extend(source);
}
(_, value) => {
target.insert(namespace, value);
}
}
}
}
pub(crate) fn canonical_tool_to_openai(tool: &CanonicalToolDefinition) -> Value {
let mut function = Map::new();
function.insert("name".to_string(), Value::String(tool.name.clone()));
@@ -2901,6 +2971,31 @@ pub(crate) fn openai_responses_tool_choice_to_canonical(
object
.get("name")
.and_then(Value::as_str)
.or_else(|| {
object
.get("function")
.and_then(Value::as_object)
.and_then(|function| function.get("name"))
.and_then(Value::as_str)
})
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|name| CanonicalToolChoice::Tool {
name: name.to_string(),
})
} else if choice_type == "custom" {
object
.get("name")
.and_then(Value::as_str)
.or_else(|| {
object
.get("custom")
.and_then(Value::as_object)
.and_then(|custom| custom.get("name"))
.and_then(Value::as_str)
})
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|name| CanonicalToolChoice::Tool {
name: name.to_string(),
})
@@ -4271,6 +4366,72 @@ mod tests {
assert_eq!(rebuilt["tool_choice"]["name"], "lookup");
}
#[test]
fn openai_responses_request_adapter_accepts_nested_function_and_custom_tools() {
let request = json!({
"model": "gpt-5",
"input": "Use a tool",
"tools": [
{
"type": "function",
"function": {
"name": "lookup_weather",
"description": "Lookup weather",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string"}
},
"required": ["city"]
}
}
},
{
"type": "custom",
"custom": {
"name": "shell_command",
"description": "Run a shell command"
}
}
],
"tool_choice": {
"type": "function",
"function": {"name": "lookup_weather"}
}
});
let canonical =
from_openai_responses_to_canonical_request(&request).expect("canonical request");
assert_eq!(canonical.tools.len(), 2);
assert_eq!(canonical.tools[0].name, "lookup_weather");
assert_eq!(
canonical.tools[0]
.parameters
.as_ref()
.and_then(|value| value.get("required"))
.and_then(Value::as_array)
.map(Vec::len),
Some(1)
);
assert_eq!(canonical.tools[1].name, "shell_command");
assert!(matches!(
canonical.tool_choice,
Some(super::CanonicalToolChoice::Tool { ref name }) if name == "lookup_weather"
));
let claude = canonical_to_claude_request(&canonical, "claude-sonnet-4-upstream", false)
.expect("claude request");
assert_eq!(claude["tools"][0]["name"], "lookup_weather");
assert_eq!(claude["tools"][1]["name"], "shell_command");
assert_eq!(claude["tool_choice"]["name"], "lookup_weather");
let rebuilt = canonical_to_openai_responses_request(&canonical, "gpt-5-upstream", false)
.expect("openai responses request");
assert_eq!(rebuilt["tools"][0]["name"], "lookup_weather");
assert_eq!(rebuilt["tools"][1]["type"], "custom");
assert_eq!(rebuilt["tools"][1]["custom"]["name"], "shell_command");
}
#[test]
fn openai_responses_response_adapter_preserves_output_items_reasoning_tools_and_usage() {
let response = json!({

View File

@@ -447,7 +447,9 @@ fn canonical_tool_to_responses(tool: &CanonicalToolDefinition) -> Value {
value
.get("type")
.and_then(Value::as_str)
.is_some_and(|tool_type| tool_type.starts_with("web_search"))
.is_some_and(|tool_type| {
tool_type == "custom" || tool_type.starts_with("web_search")
})
})
{
return raw.clone();