fix(llm): 修复 OpenaiResponseProvider builtin_tools 注入逻辑缺陷

修复当 tools_defs 为空时 builtin_tools 完全不生效的问题:
- 修复 convert_request 分支错位,解耦 tools_defs 与 builtin_tools 处理
- 扩展 ResponseTool 枚举,添加 Builtin(Value) 变体 + 自定义 Serialize/Deserialize
- 改进错误处理:match + warn! 替代 unwrap_or_else 兜底
- 添加 6 个测试用例覆盖纯 builtin / 混用 / 回归 / wire format / roundtrip 场景
- 补充方案文档 docs/30-builtin-tools-injection-fix.md
This commit is contained in:
徐涛
2026-07-20 16:45:47 +08:00
parent 8ea01d373e
commit 40e4b3d8fe
2 changed files with 490 additions and 21 deletions
+222 -21
View File
@@ -134,15 +134,68 @@ pub(crate) enum ResponseInputContent {
}
/// Tool 定义。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
///
/// NOTE: 仅在请求序列化路径使用(convert_request → build_request_builder → HTTP body)。
/// 响应反序列化走 ResponseOutputItem,不经过此类型。
/// 自定义 Deserialize 服务于 convert_request 内 extra 字段反序列化。
#[derive(Debug, Clone)]
pub(crate) enum ResponseTool {
#[serde(rename = "function")]
Function {
name: String,
description: String,
parameters: Value,
},
/// 非 function 类型的工具(如 web_search / file_search / code_interpreter)。
/// 直接透传原始 JSON Value,不做结构化解析,避免信息丢失。
Builtin(Value),
}
impl Serialize for ResponseTool {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
ResponseTool::Function {
name,
description,
parameters,
} => {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(Some(4))?;
map.serialize_entry("type", "function")?;
map.serialize_entry("name", name)?;
map.serialize_entry("description", description)?;
map.serialize_entry("parameters", parameters)?;
map.end()
}
ResponseTool::Builtin(value) => value.serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for ResponseTool {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = Value::deserialize(deserializer)?;
match value.get("type").and_then(|t| t.as_str()) {
Some("function") => {
let name = value
.get("name")
.and_then(|n| n.as_str())
.unwrap_or_default()
.to_string();
let description = value
.get("description")
.and_then(|d| d.as_str())
.unwrap_or_default()
.to_string();
let parameters = value.get("parameters").cloned().unwrap_or(Value::Null);
Ok(ResponseTool::Function {
name,
description,
parameters,
})
}
_ => Ok(ResponseTool::Builtin(value)),
}
}
}
/// OpenAI Response API 响应体。
@@ -612,31 +665,41 @@ impl OpenaiResponseProvider {
Some(instructions_parts.join("\n"))
};
let tools = if tools_defs.is_empty() {
None
} else {
let mut items: Vec<ResponseTool> = tools_defs
.into_iter()
.map(|t| ResponseTool::Function {
let tools = {
let mut items: Vec<ResponseTool> = Vec::new();
// 1. 自定义工具 → Function 变体
for t in tools_defs {
items.push(ResponseTool::Function {
name: t.name,
description: t.description.unwrap_or_default(),
parameters: t.parameters,
})
.collect();
// ponytail: 内置工具(web_search / file_search)通过 extra 逃生舱追加到 tools 数组。
});
}
// 2. 内置工具 → Builtin 变体(与 tools_defs 解耦)
// 调用方使用 `request.set_extra("builtin_tools", vec![json!({"type":"web_search"})])` 注入。
if let Some(extra) = builtin_tools {
for v in extra {
items.push(serde_json::from_value(v).unwrap_or_else(|_| {
ResponseTool::Function {
name: String::new(),
description: String::new(),
parameters: Value::Null,
if let Some(extra_tools) = builtin_tools {
for v in extra_tools {
match serde_json::from_value(v.clone()) {
Ok(tool) => items.push(tool),
// ponytail: ResponseTool::Deserialize 对任何 Value 都返回 Ok
// "function" 分支抽字段,其余走 Builtin),因此 Err 分支实际不可达。
// 防御性保留:未来若 ResponseTool 增加严格校验逻辑,此分支即可激活。
Err(e) => {
let raw = serde_json::to_string(&v).unwrap_or_default();
warn!(tool = %raw, error = %e, "skipped invalid builtin_tool");
}
}));
}
}
}
Some(items)
// 3. 两者都空 → None;否则 → Some
if items.is_empty() {
None
} else {
Some(items)
}
};
// ponytail: 顶层 `text.format` 通过 extra 逃生舱透传 —— 整体结构化为 value 后塞入。
@@ -2308,4 +2371,142 @@ data: {\"type\":\"response.failed\",\"error\":{\"message\":\"server failed mid-s
"非法 header 名应被静默跳过"
);
}
// ===== builtin_tools 注入测试(Phase 30 =====
/// T1: 纯内置工具场景 —— tools_defs 为空、builtin_tools 非空。
#[test]
fn convert_request_builtin_tools_only() {
let provider = make_provider("http://x".into());
let mut req = MessageRequest {
model: "gpt-4o".into(),
messages: vec![Message::user_text("search web")],
tools: vec![],
..Default::default()
};
req.set_extra(
"builtin_tools",
json!([{"type": "web_search", "search_context_size": "medium"}]),
);
let body = provider.convert_request(req).unwrap();
assert!(body.tools.is_some(), "tools should be Some with builtin tools");
let tools = body.tools.as_ref().unwrap();
assert_eq!(tools.len(), 1);
// 验证 wire 序列化后 type 字段正确
let wire = serde_json::to_value(tools).unwrap();
assert_eq!(wire[0]["type"], "web_search");
assert_eq!(wire[0]["search_context_size"], "medium");
}
/// T2: 混用场景 —— tools_defs + builtin_tools 同时非空。
#[test]
fn convert_request_mixed_tools() {
let provider = make_provider("http://x".into());
let mut req = MessageRequest {
model: "gpt-4o".into(),
messages: vec![Message::user_text("compute and search")],
tools: vec![ToolDef {
name: "my_func".into(),
description: Some("a custom function".into()),
parameters: json!({"type": "object"}),
}],
..Default::default()
};
req.set_extra("builtin_tools", json!([{"type": "code_interpreter"}]));
let body = provider.convert_request(req).unwrap();
let tools = body.tools.as_ref().unwrap();
assert_eq!(tools.len(), 2);
let wire = serde_json::to_value(tools).unwrap();
assert_eq!(wire[0]["type"], "function");
assert_eq!(wire[0]["name"], "my_func");
assert_eq!(wire[1]["type"], "code_interpreter");
}
/// T3: 两端空 —— 回归测试。
#[test]
fn convert_request_no_tools_at_all() {
let provider = make_provider("http://x".into());
let req = MessageRequest {
model: "gpt-4o".into(),
messages: vec![Message::user_text("hi")],
tools: vec![],
..Default::default()
};
let body = provider.convert_request(req).unwrap();
assert!(body.tools.is_none(), "tools should be None when no tools at all");
}
/// T4: 多个有效 builtin_tools 都被正确保留。
///
/// 设计说明:自定义 Deserialize 对任何 Value 都返回 OkFunction 或 Builtin),
/// 因此 `from_value::<ResponseTool>` 实际不会失败。本测试验证多个有效 builtin_tools
/// 都能被正确处理并保留。
#[test]
fn convert_request_valid_builtin_tools_retained() {
let provider = make_provider("http://x".into());
let mut req = MessageRequest {
model: "gpt-4o".into(),
messages: vec![Message::user_text("hi")],
tools: vec![],
..Default::default()
};
// 传入多个有效 builtin tool 值(含一个看似"非 function"的字符串值,
// 会被反序列化为 Builtin 变体 —— 这是设计预期)
req.set_extra(
"builtin_tools",
json!([
{"type": "web_search"},
{"type": "file_search", "max_num_results": 5}
]),
);
let body = provider.convert_request(req).unwrap();
let tools = body.tools.as_ref().unwrap();
assert_eq!(tools.len(), 2, "both builtin_tools should be retained");
}
/// T5: ResponseTool::Function 序列化后的 JSON 结构(AC7 wire format 回归)。
///
/// 注:serde_json::Map 默认是 BTreeMap,序列化后字段按字母序排列;
/// JSON wire 格式不要求字段顺序,OpenAI API 不会拒绝任意字段顺序。
#[test]
fn test_function_wire_format() {
let tool = ResponseTool::Function {
name: "search".into(),
description: "search docs".into(),
parameters: json!({"type": "object"}),
};
let wire = serde_json::to_value(&tool).unwrap();
// 验证字段值正确(顺序无关)
assert_eq!(wire["type"], "function");
assert_eq!(wire["name"], "search");
assert_eq!(wire["description"], "search docs");
assert_eq!(wire["parameters"], json!({"type": "object"}));
// 验证字段集合完整(无缺失、无多余)
let obj = wire.as_object().unwrap();
let mut keys: Vec<&str> = obj.keys().map(|s| s.as_str()).collect();
keys.sort();
assert_eq!(keys, vec!["description", "name", "parameters", "type"]);
}
/// T6: ResponseTool::Builtin 反序列化 + 序列化 roundtrip —— 原始 JSON 结构保留。
#[test]
fn test_builtin_roundtrip() {
let original = json!({
"type": "web_search",
"search_context_size": "high",
"user_location": {"country": "US"}
});
let tool: ResponseTool = serde_json::from_value(original.clone()).unwrap();
// 验证反序列化为 Builtin 变体
match &tool {
ResponseTool::Builtin(value) => {
assert_eq!(value["type"], "web_search");
assert_eq!(value["user_location"]["country"], "US");
}
_ => panic!("expected Builtin variant"),
}
// 验证序列化后原始结构完整保留
let wire = serde_json::to_value(&tool).unwrap();
assert_eq!(wire, original);
}
}