Skip to content

Commit 622575e

Browse files
committed
fix(tool_parser): resolve nullable/union schema types for coercion
param_types_for_function only read a scalar `type`, so an optional string declared as {"type": ["string", "null"]} or via anyOf/oneOf produced no entry and fell back to inference (coercing e.g. "4" to a number). Resolve such schemas to their non-null type so optional strings stay strings. Shared helper, so qwen_xml/glm4_moe/minimax_m2 all benefit. (No BFCL schema uses unions today, so scores are unchanged; this is real-world robustness.) Signed-off-by: key4ng <rukeyang@gmail.com>
1 parent c26fa18 commit 622575e

1 file changed

Lines changed: 53 additions & 2 deletions

File tree

crates/tool_parser/src/parsers/helpers.rs

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,33 @@ use crate::{
99
types::{StreamingParseResult, ToolCallItem},
1010
};
1111

12+
/// Resolve a property schema to a single representative JSON-schema type,
13+
/// looking through the common nullable/union spellings so an optional `string`
14+
/// is still treated as `string`:
15+
/// - scalar: `{"type": "string"}`
16+
/// - nullable array: `{"type": ["string", "null"]}` (first non-`null`)
17+
/// - union: `{"anyOf"|"oneOf": [{"type": "string"}, {"type": "null"}]}`
18+
fn schema_type(schema: &Value) -> Option<&str> {
19+
if let Some(t) = schema.get("type").and_then(Value::as_str) {
20+
return Some(t);
21+
}
22+
if let Some(arr) = schema.get("type").and_then(Value::as_array) {
23+
return arr.iter().filter_map(Value::as_str).find(|t| *t != "null");
24+
}
25+
["anyOf", "oneOf"].iter().find_map(|key| {
26+
schema
27+
.get(key)
28+
.and_then(Value::as_array)?
29+
.iter()
30+
.filter_map(schema_type)
31+
.find(|t| *t != "null")
32+
})
33+
}
34+
1235
/// `param_name -> declared JSON-schema type` for the named function (empty if the
1336
/// function or its `properties` are absent). Lets XML-style parsers coerce by the
1437
/// declared type instead of guessing from text (e.g. keep a numeric-looking
15-
/// `string` as a string).
38+
/// `string` as a string). Nullable/union schemas resolve to their non-`null` type.
1639
pub fn param_types_for_function(tools: &[Tool], func_name: &str) -> HashMap<String, String> {
1740
let mut types = HashMap::new();
1841
let Some(tool) = tools.iter().find(|t| t.function.name == func_name) else {
@@ -25,7 +48,7 @@ pub fn param_types_for_function(tools: &[Tool], func_name: &str) -> HashMap<Stri
2548
.and_then(Value::as_object)
2649
{
2750
for (key, schema) in props {
28-
if let Some(ty) = schema.get("type").and_then(Value::as_str) {
51+
if let Some(ty) = schema_type(schema) {
2952
types.insert(key.clone(), ty.to_string());
3053
}
3154
}
@@ -661,4 +684,32 @@ mod tests {
661684
assert_eq!(coerce_by_schema_type("4", None), None);
662685
assert_eq!(coerce_by_schema_type("abc", Some("integer")), None);
663686
}
687+
688+
#[test]
689+
fn test_param_types_nullable_and_union() {
690+
use openai_protocol::common::Function;
691+
let tools = vec![Tool {
692+
tool_type: "function".to_string(),
693+
function: Function {
694+
name: "f".to_string(),
695+
description: None,
696+
parameters: serde_json::json!({
697+
"type": "object",
698+
"properties": {
699+
"a": {"type": "string"},
700+
"b": {"type": ["string", "null"]},
701+
"c": {"anyOf": [{"type": "string"}, {"type": "null"}]},
702+
"d": {"type": ["null", "integer"]},
703+
}
704+
}),
705+
strict: None,
706+
},
707+
}];
708+
let types = param_types_for_function(&tools, "f");
709+
// Optional/nullable strings still resolve to "string" (not dropped).
710+
assert_eq!(types.get("a").map(String::as_str), Some("string"));
711+
assert_eq!(types.get("b").map(String::as_str), Some("string"));
712+
assert_eq!(types.get("c").map(String::as_str), Some("string"));
713+
assert_eq!(types.get("d").map(String::as_str), Some("integer"));
714+
}
664715
}

0 commit comments

Comments
 (0)