| use serde_json::{json, Value}; |
| use once_cell::sync::Lazy; |
| use super::tool_adapter::ToolAdapter; |
| use super::tool_adapters::PencilAdapter; |
|
|
| |
| |
| const CONSTRAINT_FIELDS: &[(&str, &str)] = &[ |
| ("minLength", "minLen"), |
| ("maxLength", "maxLen"), |
| ("pattern", "pattern"), |
| ("minimum", "min"), |
| ("maximum", "max"), |
| ("multipleOf", "multipleOf"), |
| ("exclusiveMinimum", "exclMin"), |
| ("exclusiveMaximum", "exclMax"), |
| ("minItems", "minItems"), |
| ("maxItems", "maxItems"), |
| ("format", "format"), |
| ]; |
|
|
| |
| |
| |
| static TOOL_ADAPTERS: Lazy<Vec<Box<dyn ToolAdapter>>> = Lazy::new(|| { |
| vec![ |
| Box::new(PencilAdapter), |
| |
| |
| |
| ] |
| }); |
|
|
| const MAX_RECURSION_DEPTH: usize = 10; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| pub fn clean_json_schema(value: &mut Value) { |
| |
| |
| let mut all_defs = serde_json::Map::new(); |
| collect_all_defs(value, &mut all_defs); |
|
|
| |
| if let Value::Object(map) = value { |
| map.remove("$defs"); |
| map.remove("definitions"); |
| } |
|
|
| |
| |
| if let Value::Object(map) = value { |
| flatten_refs(map, &all_defs, 0); |
| } |
|
|
| |
| clean_json_schema_recursive(value, true, 0); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| pub fn clean_json_schema_for_tool(value: &mut Value, tool_name: &str) { |
| |
| let adapter = TOOL_ADAPTERS.iter() |
| .find(|a| a.matches(tool_name)); |
| |
| |
| if let Some(adapter) = adapter { |
| let _ = adapter.pre_process(value); |
| } |
| |
| |
| clean_json_schema(value); |
| |
| |
| if let Some(adapter) = adapter { |
| let _ = adapter.post_process(value); |
| } |
| } |
|
|
| |
| |
| |
| |
| fn collect_all_defs(value: &Value, defs: &mut serde_json::Map<String, Value>) { |
| if let Value::Object(map) = value { |
| |
| if let Some(Value::Object(d)) = map.get("$defs") { |
| for (k, v) in d { |
| |
| defs.entry(k.clone()).or_insert_with(|| v.clone()); |
| } |
| } |
| |
| if let Some(Value::Object(d)) = map.get("definitions") { |
| for (k, v) in d { |
| defs.entry(k.clone()).or_insert_with(|| v.clone()); |
| } |
| } |
| |
| for (key, v) in map { |
| |
| if key != "$defs" && key != "definitions" { |
| collect_all_defs(v, defs); |
| } |
| } |
| } else if let Value::Array(arr) = value { |
| for item in arr { |
| collect_all_defs(item, defs); |
| } |
| } |
| } |
|
|
| |
| fn flatten_refs( |
| map: &mut serde_json::Map<String, Value>, |
| defs: &serde_json::Map<String, Value>, |
| depth: usize, |
| ) { |
| if depth > MAX_RECURSION_DEPTH { |
| tracing::warn!("[Schema-Flatten] Max recursion depth reached, stopping ref expansion."); |
| return; |
| } |
|
|
| |
| if let Some(Value::String(ref_path)) = map.remove("$ref") { |
| |
| let ref_name = ref_path.split('/').last().unwrap_or(&ref_path); |
|
|
| if let Some(def_schema) = defs.get(ref_name) { |
| |
| if let Value::Object(def_map) = def_schema { |
| for (k, v) in def_map { |
| |
| |
| map.entry(k.clone()).or_insert_with(|| v.clone()); |
| } |
|
|
| |
| |
| flatten_refs(map, defs, depth + 1); |
| } |
| } else { |
| |
| |
| map.insert("type".to_string(), serde_json::json!("string")); |
| let hint = format!("(Unresolved $ref: {})", ref_path); |
| let desc_val = map |
| .entry("description".to_string()) |
| .or_insert_with(|| Value::String(String::new())); |
| if let Value::String(s) = desc_val { |
| if !s.contains(&hint) { |
| if !s.is_empty() { |
| s.push(' '); |
| } |
| s.push_str(&hint); |
| } |
| } |
| } |
| } |
|
|
| |
| for (_, v) in map.iter_mut() { |
| if let Value::Object(child_map) = v { |
| flatten_refs(child_map, defs, depth + 1); |
| } else if let Value::Array(arr) = v { |
| for item in arr { |
| if let Value::Object(item_map) = item { |
| flatten_refs(item_map, defs, depth + 1); |
| } |
| } |
| } |
| } |
| } |
|
|
| fn clean_json_schema_recursive(value: &mut Value, is_schema_node: bool, depth: usize) -> bool { |
| if depth > MAX_RECURSION_DEPTH { |
| debug_assert!(false, "Max recursion depth reached in clean_json_schema_recursive"); |
| return false; |
| } |
| let mut is_effectively_nullable = false; |
|
|
| match value { |
| Value::Object(map) => { |
| |
| merge_all_of(map); |
|
|
| |
| |
| |
| |
| if map.get("type").and_then(|t| t.as_str()) == Some("object") || map.contains_key("properties") { |
| if let Some(items) = map.remove("items") { |
| tracing::warn!("[Schema-Normalization] Found 'items' in an Object-like node. Moving content to 'properties'."); |
| let target_props = map.entry("properties".to_string()).or_insert_with(|| json!({})); |
| if let Some(target_map) = target_props.as_object_mut() { |
| if let Some(source_map) = items.as_object() { |
| for (k, v) in source_map { |
| target_map.entry(k.clone()).or_insert_with(|| v.clone()); |
| } |
| } |
| } |
| } |
| } |
|
|
| |
| |
| if let Some(Value::Object(props)) = map.get_mut("properties") { |
| let mut nullable_keys = std::collections::HashSet::new(); |
| for (k, v) in props { |
| |
| if clean_json_schema_recursive(v, true, depth + 1) { |
| nullable_keys.insert(k.clone()); |
| } |
| } |
|
|
| if !nullable_keys.is_empty() { |
| if let Some(Value::Array(req_arr)) = map.get_mut("required") { |
| req_arr.retain(|r| { |
| r.as_str() |
| .map(|s| !nullable_keys.contains(s)) |
| .unwrap_or(true) |
| }); |
| if req_arr.is_empty() { |
| map.remove("required"); |
| } |
| } |
| } |
|
|
| |
| if !map.contains_key("type") { |
| map.insert("type".to_string(), Value::String("object".to_string())); |
| } |
| } |
| |
| |
| if let Some(items) = map.get_mut("items") { |
| |
| clean_json_schema_recursive(items, true, depth + 1); |
|
|
| |
| if !map.contains_key("type") { |
| map.insert("type".to_string(), Value::String("array".to_string())); |
| } |
| } |
|
|
| |
| if !map.contains_key("properties") && !map.contains_key("items") { |
| for (k, v) in map.iter_mut() { |
| |
| if k != "anyOf" && k != "oneOf" && k != "allOf" && k != "enum" && k != "type" { |
| clean_json_schema_recursive(v, false, depth + 1); |
| } |
| } |
| } |
|
|
| |
| |
| if let Some(Value::Array(any_of)) = map.get_mut("anyOf") { |
| for branch in any_of.iter_mut() { |
| clean_json_schema_recursive(branch, true, depth + 1); |
| } |
| } |
| if let Some(Value::Array(one_of)) = map.get_mut("oneOf") { |
| for branch in one_of.iter_mut() { |
| clean_json_schema_recursive(branch, true, depth + 1); |
| } |
| } |
|
|
| |
| let mut union_to_merge = None; |
| if let Some(Value::Array(any_of)) = map.get("anyOf") { |
| union_to_merge = Some(any_of.clone()); |
| } else if let Some(Value::Array(one_of)) = map.get("oneOf") { |
| union_to_merge = Some(one_of.clone()); |
| } |
|
|
| if let Some(union_array) = union_to_merge { |
| if let Some((best_branch, all_types)) = extract_best_schema_from_union(&union_array) { |
| if let Value::Object(branch_obj) = best_branch { |
| |
| for (k, v) in branch_obj { |
| if k == "properties" { |
| if let Some(target_props) = map |
| .entry("properties".to_string()) |
| .or_insert_with(|| Value::Object(serde_json::Map::new())) |
| .as_object_mut() |
| { |
| if let Some(source_props) = v.as_object() { |
| for (pk, pv) in source_props { |
| target_props |
| .entry(pk.clone()) |
| .or_insert_with(|| pv.clone()); |
| } |
| } |
| } |
| } else if k == "required" { |
| if let Some(target_req) = map |
| .entry("required".to_string()) |
| .or_insert_with(|| Value::Array(Vec::new())) |
| .as_array_mut() |
| { |
| if let Some(source_req) = v.as_array() { |
| for rv in source_req { |
| if !target_req.contains(rv) { |
| target_req.push(rv.clone()); |
| } |
| } |
| } |
| } |
| } else if !map.contains_key(&k) { |
| map.insert(k, v); |
| } |
| } |
| } |
| |
| |
| if all_types.len() > 1 { |
| let type_hint = format!("Accepts: {}", all_types.join(" | ")); |
| append_hint_to_description(map, type_hint); |
| } |
| } |
| } |
|
|
| |
| |
| |
| let allowed_fields = [ |
| "type", |
| "description", |
| "properties", |
| "required", |
| "items", |
| "enum", |
| "title", |
| ]; |
| |
| let has_standard_keyword = map.keys().any(|k| allowed_fields.contains(&k.as_str())); |
|
|
| |
| |
| |
| let is_not_schema_payload = map.contains_key("functionCall") || map.contains_key("functionResponse"); |
| if is_schema_node && !has_standard_keyword && !map.is_empty() && !is_not_schema_payload { |
| let mut properties = serde_json::Map::new(); |
| let keys: Vec<String> = map.keys().cloned().collect(); |
| for k in keys { |
| if let Some(v) = map.remove(&k) { |
| properties.insert(k, v); |
| } |
| } |
| map.insert("type".to_string(), Value::String("object".to_string())); |
| map.insert("properties".to_string(), Value::Object(properties)); |
| |
| |
| if let Some(Value::Object(props_map)) = map.get_mut("properties") { |
| for v in props_map.values_mut() { |
| clean_json_schema_recursive(v, true, depth + 1); |
| } |
| } |
| } |
|
|
| let looks_like_schema = (is_schema_node || has_standard_keyword) && !is_not_schema_payload; |
|
|
| if looks_like_schema { |
| |
| |
| move_constraints_to_description(map); |
|
|
| |
| let keys_to_remove: Vec<String> = map |
| .keys() |
| .filter(|k| !allowed_fields.contains(&k.as_str())) |
| .cloned() |
| .collect(); |
| for k in keys_to_remove { |
| map.remove(&k); |
| } |
|
|
| |
| |
| |
| |
| |
| if map.get("type").and_then(|t| t.as_str()) == Some("object") { |
| if !map.contains_key("properties") { |
| map.insert("properties".to_string(), serde_json::json!({})); |
| } |
| } |
|
|
| |
| let valid_prop_keys: Option<std::collections::HashSet<String>> = map |
| .get("properties") |
| .and_then(|p| p.as_object()) |
| .map(|obj| obj.keys().cloned().collect()); |
|
|
| if let Some(required_val) = map.get_mut("required") { |
| if let Some(req_arr) = required_val.as_array_mut() { |
| if let Some(keys) = &valid_prop_keys { |
| req_arr |
| .retain(|k| k.as_str().map(|s| keys.contains(s)).unwrap_or(false)); |
| } else { |
| req_arr.clear(); |
| } |
| } |
| } |
|
|
| if !map.contains_key("type") { |
| if map.contains_key("enum") { |
| map.insert("type".to_string(), Value::String("string".to_string())); |
| } else if map.contains_key("properties") { |
| map.insert("type".to_string(), Value::String("object".to_string())); |
| } else if map.contains_key("items") { |
| map.insert("type".to_string(), Value::String("array".to_string())); |
| } |
| } |
|
|
| |
| let fallback = if map.contains_key("properties") { |
| "object" |
| } else if map.contains_key("items") { |
| "array" |
| } else { |
| "string" |
| }; |
|
|
| |
| if let Some(type_val) = map.get_mut("type") { |
| let mut selected_type = None; |
| match type_val { |
| Value::String(s) => { |
| let lower = s.to_lowercase(); |
| if lower == "null" { |
| is_effectively_nullable = true; |
| } else { |
| selected_type = Some(lower); |
| } |
| } |
| Value::Array(arr) => { |
| for item in arr { |
| if let Value::String(s) = item { |
| let lower = s.to_lowercase(); |
| if lower == "null" { |
| is_effectively_nullable = true; |
| } else if selected_type.is_none() { |
| selected_type = Some(lower); |
| } |
| } |
| } |
| } |
| _ => {} |
| } |
| |
| *type_val = |
| Value::String(selected_type.unwrap_or_else(|| fallback.to_string())); |
| } |
|
|
| if is_effectively_nullable { |
| let desc_val = map |
| .entry("description".to_string()) |
| .or_insert_with(|| Value::String("".to_string())); |
| if let Value::String(s) = desc_val { |
| if !s.contains("nullable") { |
| if !s.is_empty() { |
| s.push(' '); |
| } |
| s.push_str("(nullable)"); |
| } |
| } |
| } |
|
|
| |
| if let Some(Value::Array(arr)) = map.get_mut("enum") { |
| for item in arr { |
| if !item.is_string() { |
| *item = Value::String(if item.is_null() { |
| "null".to_string() |
| } else { |
| item.to_string() |
| }); |
| } |
| } |
| } |
| } |
| } |
| Value::Array(arr) => { |
| |
| |
| for item in arr.iter_mut() { |
| clean_json_schema_recursive(item, is_schema_node, depth + 1); |
| } |
| } |
| _ => {} |
| } |
|
|
| is_effectively_nullable |
| } |
|
|
| |
| fn merge_all_of(map: &mut serde_json::Map<String, Value>) { |
| if let Some(Value::Array(all_of)) = map.remove("allOf") { |
| let mut merged_properties = serde_json::Map::new(); |
| let mut merged_required = std::collections::HashSet::new(); |
| let mut other_fields = serde_json::Map::new(); |
|
|
| for sub_schema in all_of { |
| if let Value::Object(sub_map) = sub_schema { |
| |
| if let Some(Value::Object(props)) = sub_map.get("properties") { |
| for (k, v) in props { |
| merged_properties.insert(k.clone(), v.clone()); |
| } |
| } |
|
|
| |
| if let Some(Value::Array(reqs)) = sub_map.get("required") { |
| for req in reqs { |
| if let Some(s) = req.as_str() { |
| merged_required.insert(s.to_string()); |
| } |
| } |
| } |
|
|
| |
| for (k, v) in sub_map { |
| if k != "properties" |
| && k != "required" |
| && k != "allOf" |
| && !other_fields.contains_key(&k) |
| { |
| other_fields.insert(k, v); |
| } |
| } |
| } |
| } |
|
|
| |
| for (k, v) in other_fields { |
| if !map.contains_key(&k) { |
| map.insert(k, v); |
| } |
| } |
|
|
| if !merged_properties.is_empty() { |
| let existing_props = map |
| .entry("properties".to_string()) |
| .or_insert_with(|| Value::Object(serde_json::Map::new())); |
| if let Value::Object(existing_map) = existing_props { |
| for (k, v) in merged_properties { |
| existing_map.entry(k).or_insert(v); |
| } |
| } |
| } |
|
|
| if !merged_required.is_empty() { |
| let existing_reqs = map |
| .entry("required".to_string()) |
| .or_insert_with(|| Value::Array(Vec::new())); |
| if let Value::Array(req_arr) = existing_reqs { |
| let mut current_reqs: std::collections::HashSet<String> = req_arr |
| .iter() |
| .filter_map(|v| v.as_str().map(|s| s.to_string())) |
| .collect(); |
| for req in merged_required { |
| if current_reqs.insert(req.clone()) { |
| req_arr.push(Value::String(req)); |
| } |
| } |
| } |
| } |
| } |
| } |
|
|
| |
| |
| fn append_hint_to_description(map: &mut serde_json::Map<String, Value>, hint: String) { |
| let desc_val = map |
| .entry("description".to_string()) |
| .or_insert_with(|| Value::String("".to_string())); |
| |
| if let Value::String(s) = desc_val { |
| if s.is_empty() { |
| *s = hint; |
| } else if !s.contains(&hint) { |
| *s = format!("{} {}", s, hint); |
| } |
| } |
| } |
|
|
| |
| |
| fn move_constraints_to_description(map: &mut serde_json::Map<String, Value>) { |
| let mut hints = Vec::new(); |
| |
| for (field, label) in CONSTRAINT_FIELDS { |
| if let Some(val) = map.get(*field) { |
| if !val.is_null() { |
| let val_str = if let Some(s) = val.as_str() { |
| s.to_string() |
| } else { |
| val.to_string() |
| }; |
| hints.push(format!("{}: {}", label, val_str)); |
| } |
| } |
| } |
| |
| if !hints.is_empty() { |
| let constraint_hint = format!("[Constraint: {}]", hints.join(", ")); |
| append_hint_to_description(map, constraint_hint); |
| } |
| } |
|
|
| |
| |
| fn score_schema_option(val: &Value) -> i32 { |
| if let Value::Object(obj) = val { |
| if obj.contains_key("properties") |
| || obj.get("type").and_then(|t| t.as_str()) == Some("object") |
| { |
| return 3; |
| } |
| if obj.contains_key("items") || obj.get("type").and_then(|t| t.as_str()) == Some("array") { |
| return 2; |
| } |
| if let Some(type_str) = obj.get("type").and_then(|t| t.as_str()) { |
| if type_str != "null" { |
| return 1; |
| } |
| } |
| } |
| 0 |
| } |
|
|
|
|
| |
| |
| |
| fn extract_best_schema_from_union(union_array: &Vec<Value>) -> Option<(Value, Vec<String>)> { |
| let mut best_option: Option<&Value> = None; |
| let mut best_score = -1; |
| let mut all_types = Vec::new(); |
|
|
| for item in union_array { |
| let score = score_schema_option(item); |
| |
| |
| if let Some(type_str) = get_schema_type_name(item) { |
| if !all_types.contains(&type_str) { |
| all_types.push(type_str); |
| } |
| } |
| |
| if score > best_score { |
| best_score = score; |
| best_option = Some(item); |
| } |
| } |
|
|
| best_option.cloned().map(|schema| (schema, all_types)) |
| } |
|
|
| |
| fn get_schema_type_name(schema: &Value) -> Option<String> { |
| if let Value::Object(obj) = schema { |
| |
| if let Some(type_val) = obj.get("type") { |
| if let Some(s) = type_val.as_str() { |
| return Some(s.to_string()); |
| } |
| } |
| |
| |
| if obj.contains_key("properties") { |
| return Some("object".to_string()); |
| } |
| if obj.contains_key("items") { |
| return Some("array".to_string()); |
| } |
| } |
| |
| None |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| pub fn fix_tool_call_args(args: &mut Value, schema: &Value) { |
| if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) { |
| if let Some(args_obj) = args.as_object_mut() { |
| for (key, value) in args_obj.iter_mut() { |
| if let Some(prop_schema) = properties.get(key) { |
| fix_single_arg_recursive(value, prop_schema); |
| } |
| } |
| } |
| } |
| } |
|
|
| |
| fn fix_single_arg_recursive(value: &mut Value, schema: &Value) { |
| |
| if let Some(nested_props) = schema.get("properties").and_then(|p| p.as_object()) { |
| if let Some(value_obj) = value.as_object_mut() { |
| for (key, nested_value) in value_obj.iter_mut() { |
| if let Some(nested_schema) = nested_props.get(key) { |
| fix_single_arg_recursive(nested_value, nested_schema); |
| } |
| } |
| } |
| return; |
| } |
|
|
| |
| let schema_type = schema |
| .get("type") |
| .and_then(|t| t.as_str()) |
| .unwrap_or("") |
| .to_lowercase(); |
| if schema_type == "array" { |
| if let Some(items_schema) = schema.get("items") { |
| if let Some(arr) = value.as_array_mut() { |
| for item in arr { |
| fix_single_arg_recursive(item, items_schema); |
| } |
| } |
| } |
| return; |
| } |
|
|
| |
| match schema_type.as_str() { |
| "number" | "integer" => { |
| |
| if let Some(s) = value.as_str() { |
| |
| if s.starts_with('0') && s.len() > 1 && !s.starts_with("0.") { |
| return; |
| } |
|
|
| |
| if let Ok(i) = s.parse::<i64>() { |
| *value = Value::Number(serde_json::Number::from(i)); |
| } else if let Ok(f) = s.parse::<f64>() { |
| if let Some(n) = serde_json::Number::from_f64(f) { |
| *value = Value::Number(n); |
| } |
| } |
| } |
| } |
| "boolean" => { |
| |
| if let Some(s) = value.as_str() { |
| match s.to_lowercase().as_str() { |
| "true" | "1" | "yes" | "on" => *value = Value::Bool(true), |
| "false" | "0" | "no" | "off" => *value = Value::Bool(false), |
| _ => {} |
| } |
| } else if let Some(n) = value.as_i64() { |
| |
| if n == 1 { |
| *value = Value::Bool(true); |
| } else if n == 0 { |
| *value = Value::Bool(false); |
| } |
| } |
| } |
| "string" => { |
| |
| if !value.is_string() && !value.is_null() && !value.is_object() && !value.is_array() { |
| *value = Value::String(value.to_string()); |
| } |
| } |
| _ => {} |
| } |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| use serde_json::json; |
|
|
| #[test] |
| fn test_clean_json_schema_draft_2020_12() { |
| let mut schema = json!({ |
| "$schema": "http://json-schema.org/draft-07/schema#", |
| "type": "object", |
| "properties": { |
| "location": { |
| "type": "string", |
| "minLength": 1, |
| "format": "city" |
| }, |
| |
| "pattern": { |
| "type": "object", |
| "properties": { |
| "regex": { "type": "string", "pattern": "^[a-z]+$" } |
| } |
| }, |
| "unit": { |
| "type": ["string", "null"], |
| "default": "celsius" |
| } |
| }, |
| "required": ["location"] |
| }); |
|
|
| clean_json_schema(&mut schema); |
|
|
| |
| assert_eq!(schema["type"], "object"); |
| assert_eq!(schema["properties"]["location"]["type"], "string"); |
|
|
| |
| assert!(schema["properties"]["location"].get("minLength").is_none()); |
| assert!(schema["properties"]["location"].get("format").is_none()); |
| assert!(schema["properties"]["location"]["description"] |
| .as_str() |
| .unwrap() |
| .contains("[Constraint: minLen: 1, format: city]")); |
|
|
| |
| assert!(schema["properties"].get("pattern").is_some()); |
| assert_eq!(schema["properties"]["pattern"]["type"], "object"); |
|
|
| |
| assert!(schema["properties"]["pattern"]["properties"]["regex"] |
| .get("pattern") |
| .is_none()); |
| assert!( |
| schema["properties"]["pattern"]["properties"]["regex"]["description"] |
| .as_str() |
| .unwrap() |
| .contains("[Constraint: pattern: ^[a-z]+$]") |
| ); |
|
|
| |
| assert_eq!(schema["properties"]["unit"]["type"], "string"); |
|
|
| |
| assert!(schema.get("$schema").is_none()); |
| } |
|
|
| #[test] |
| fn test_type_fallback() { |
| |
| let mut s1 = json!({"type": ["string", "null"]}); |
| clean_json_schema(&mut s1); |
| assert_eq!(s1["type"], "string"); |
|
|
| |
| let mut s2 = json!({"type": ["integer", "null"]}); |
| clean_json_schema(&mut s2); |
| assert_eq!(s2["type"], "integer"); |
| } |
|
|
| #[test] |
| fn test_flatten_refs() { |
| let mut schema = json!({ |
| "$defs": { |
| "Address": { |
| "type": "object", |
| "properties": { |
| "city": { "type": "string" } |
| } |
| } |
| }, |
| "properties": { |
| "home": { "$ref": "#/$defs/Address" } |
| } |
| }); |
|
|
| clean_json_schema(&mut schema); |
|
|
| |
| assert_eq!(schema["properties"]["home"]["type"], "object"); |
| assert_eq!( |
| schema["properties"]["home"]["properties"]["city"]["type"], |
| "string" |
| ); |
| } |
|
|
| #[test] |
| fn test_clean_json_schema_missing_required() { |
| let mut schema = json!({ |
| "type": "object", |
| "properties": { |
| "existing_prop": { "type": "string" } |
| }, |
| "required": ["existing_prop", "missing_prop"] |
| }); |
|
|
| clean_json_schema(&mut schema); |
|
|
| |
| let required = schema["required"].as_array().unwrap(); |
| assert_eq!(required.len(), 1); |
| assert_eq!(required[0].as_str().unwrap(), "existing_prop"); |
| } |
|
|
| |
| #[test] |
| fn test_anyof_type_extraction() { |
| |
| let mut schema = json!({ |
| "type": "object", |
| "properties": { |
| "testo": { |
| "anyOf": [ |
| {"type": "string"}, |
| {"type": "null"} |
| ], |
| "default": null, |
| "title": "Testo" |
| }, |
| "importo": { |
| "anyOf": [ |
| {"type": "number"}, |
| {"type": "null"} |
| ], |
| "default": null, |
| "title": "Importo" |
| }, |
| "attivo": { |
| "type": "boolean", |
| "title": "Attivo" |
| } |
| } |
| }); |
|
|
| clean_json_schema(&mut schema); |
|
|
| |
| assert!(schema["properties"]["testo"].get("anyOf").is_none()); |
| assert!(schema["properties"]["importo"].get("anyOf").is_none()); |
|
|
| |
| assert_eq!(schema["properties"]["testo"]["type"], "string"); |
| assert_eq!(schema["properties"]["importo"]["type"], "number"); |
| assert_eq!(schema["properties"]["attivo"]["type"], "boolean"); |
|
|
| |
| assert!(schema["properties"]["testo"].get("default").is_none()); |
| } |
|
|
| |
| #[test] |
| fn test_oneof_type_extraction() { |
| let mut schema = json!({ |
| "properties": { |
| "value": { |
| "oneOf": [ |
| {"type": "integer"}, |
| {"type": "null"} |
| ] |
| } |
| } |
| }); |
|
|
| clean_json_schema(&mut schema); |
|
|
| assert!(schema["properties"]["value"].get("oneOf").is_none()); |
| assert_eq!(schema["properties"]["value"]["type"], "integer"); |
| } |
|
|
| |
| #[test] |
| fn test_existing_type_preserved() { |
| let mut schema = json!({ |
| "properties": { |
| "name": { |
| "type": "string", |
| "anyOf": [ |
| {"type": "number"} |
| ] |
| } |
| } |
| }); |
|
|
| clean_json_schema(&mut schema); |
|
|
| |
| assert_eq!(schema["properties"]["name"]["type"], "string"); |
| assert!(schema["properties"]["name"].get("anyOf").is_none()); |
| } |
|
|
| |
| #[test] |
| fn test_issue_815_anyof_properties_preserved() { |
| let mut schema = json!({ |
| "type": "object", |
| "properties": { |
| "config": { |
| "anyOf": [ |
| { |
| "type": "object", |
| "properties": { |
| "path": { "type": "string" }, |
| "recursive": { "type": "boolean" } |
| }, |
| "required": ["path"] |
| }, |
| { "type": "null" } |
| ] |
| } |
| } |
| }); |
|
|
| clean_json_schema(&mut schema); |
|
|
| let config = &schema["properties"]["config"]; |
|
|
| |
| assert_eq!(config["type"], "object"); |
|
|
| |
| assert!(config.get("properties").is_some()); |
| assert_eq!(config["properties"]["path"]["type"], "string"); |
| assert_eq!(config["properties"]["recursive"]["type"], "boolean"); |
|
|
| |
| let req = config["required"].as_array().unwrap(); |
| assert!(req.iter().any(|v| v == "path")); |
|
|
| |
| assert!(config.get("anyOf").is_none()); |
|
|
| |
| assert!(config["properties"].get("reason").is_none()); |
| } |
|
|
| |
| #[test] |
| fn test_clean_json_schema_on_non_schema_object() { |
| |
| let mut tool_call = json!({ |
| "functionCall": { |
| "name": "local_shell_call", |
| "args": { "command": ["ls"] }, |
| "id": "call_123" |
| } |
| }); |
|
|
| |
| clean_json_schema(&mut tool_call); |
|
|
| |
| let fc = &tool_call["functionCall"]; |
| assert_eq!(fc["name"], "local_shell_call"); |
| assert_eq!(fc["args"]["command"][0], "ls"); |
| assert_eq!(fc["id"], "call_123"); |
| } |
|
|
| |
| #[test] |
| fn test_nullable_handling_with_description() { |
| let mut schema = json!({ |
| "type": ["string", "null"], |
| "description": "User name" |
| }); |
|
|
| clean_json_schema(&mut schema); |
|
|
| |
| assert_eq!(schema["type"], "string"); |
| assert!(schema["description"] |
| .as_str() |
| .unwrap() |
| .contains("User name")); |
| assert!(schema["description"] |
| .as_str() |
| .unwrap() |
| .contains("(nullable)")); |
| } |
|
|
| |
| #[test] |
| fn test_clean_anyof_with_propertynames() { |
| let mut schema = json!({ |
| "properties": { |
| "config": { |
| "anyOf": [ |
| { |
| "type": "object", |
| "propertyNames": {"pattern": "^[a-z]+$"}, |
| "properties": { |
| "key": {"type": "string"} |
| } |
| }, |
| {"type": "null"} |
| ] |
| } |
| } |
| }); |
|
|
| clean_json_schema(&mut schema); |
|
|
| |
| let config = &schema["properties"]["config"]; |
| assert!(config.get("anyOf").is_none()); |
|
|
| |
| assert!(config.get("propertyNames").is_none()); |
|
|
| |
| assert!(config.get("properties").is_some()); |
| assert_eq!(config["properties"]["key"]["type"], "string"); |
| } |
|
|
| |
| #[test] |
| fn test_clean_items_array_with_const() { |
| let mut schema = json!({ |
| "type": "array", |
| "items": { |
| "type": "object", |
| "properties": { |
| "status": { |
| "const": "active", |
| "type": "string" |
| } |
| } |
| } |
| }); |
|
|
| clean_json_schema(&mut schema); |
|
|
| |
| let status = &schema["items"]["properties"]["status"]; |
| assert!(status.get("const").is_none()); |
|
|
| |
| assert_eq!(status["type"], "string"); |
| } |
|
|
| |
| #[test] |
| fn test_deep_nested_array_cleaning() { |
| let mut schema = json!({ |
| "properties": { |
| "data": { |
| "anyOf": [ |
| { |
| "type": "array", |
| "items": { |
| "anyOf": [ |
| { |
| "type": "object", |
| "propertyNames": {"maxLength": 10}, |
| "const": "test", |
| "properties": { |
| "name": {"type": "string"} |
| } |
| }, |
| {"type": "null"} |
| ] |
| } |
| } |
| ] |
| } |
| } |
| }); |
|
|
| clean_json_schema(&mut schema); |
|
|
| |
| let data = &schema["properties"]["data"]; |
|
|
| |
| assert!(data.get("anyOf").is_none()); |
|
|
| |
| assert!(data.get("propertyNames").is_none()); |
| assert!(data.get("const").is_none()); |
|
|
| |
| assert_eq!(data["type"], "array"); |
| if let Some(items) = data.get("items") { |
| |
| assert!(items.get("anyOf").is_none()); |
| assert!(items.get("propertyNames").is_none()); |
| assert!(items.get("const").is_none()); |
| } |
| } |
|
|
| #[test] |
| fn test_fix_tool_call_args() { |
| let mut args = serde_json::json!({ |
| "port": "8080", |
| "enabled": "true", |
| "timeout": "5.5", |
| "metadata": { |
| "retry": "3" |
| }, |
| "tags": ["1", "2"] |
| }); |
|
|
| let schema = serde_json::json!({ |
| "properties": { |
| "port": { "type": "integer" }, |
| "enabled": { "type": "boolean" }, |
| "timeout": { "type": "number" }, |
| "metadata": { |
| "type": "object", |
| "properties": { |
| "retry": { "type": "integer" } |
| } |
| }, |
| "tags": { |
| "type": "array", |
| "items": { "type": "integer" } |
| } |
| } |
| }); |
|
|
| fix_tool_call_args(&mut args, &schema); |
|
|
| assert_eq!(args["port"], 8080); |
| assert_eq!(args["enabled"], true); |
| assert_eq!(args["timeout"], 5.5); |
| assert_eq!(args["metadata"]["retry"], 3); |
| assert_eq!(args["tags"], serde_json::json!([1, 2])); |
| } |
|
|
| #[test] |
| fn test_fix_tool_call_args_protection() { |
| let mut args = serde_json::json!({ |
| "version": "01.0", |
| "code": "007" |
| }); |
|
|
| let schema = serde_json::json!({ |
| "properties": { |
| "version": { "type": "number" }, |
| "code": { "type": "integer" } |
| } |
| }); |
|
|
| fix_tool_call_args(&mut args, &schema); |
|
|
| |
| assert_eq!(args["version"], "01.0"); |
| assert_eq!(args["code"], "007"); |
| } |
|
|
| |
| #[test] |
| fn test_nested_defs_flattening() { |
| |
| let mut schema = json!({ |
| "type": "object", |
| "properties": { |
| "config": { |
| "$defs": { |
| "Address": { |
| "type": "object", |
| "properties": { |
| "city": { "type": "string" }, |
| "zip": { "type": "string" } |
| } |
| } |
| }, |
| "type": "object", |
| "properties": { |
| "home": { "$ref": "#/$defs/Address" }, |
| "work": { "$ref": "#/$defs/Address" } |
| } |
| } |
| } |
| }); |
|
|
| clean_json_schema(&mut schema); |
|
|
| |
| let home = &schema["properties"]["config"]["properties"]["home"]; |
| assert_eq!( |
| home["type"], "object", |
| "home should have type 'object' from resolved $ref" |
| ); |
| assert_eq!( |
| home["properties"]["city"]["type"], "string", |
| "home.properties.city should exist from resolved Address" |
| ); |
|
|
| |
| assert!( |
| home.get("$ref").is_none(), |
| "home should not have orphan $ref" |
| ); |
|
|
| |
| let work = &schema["properties"]["config"]["properties"]["work"]; |
| assert_eq!(work["type"], "object"); |
| assert!(work.get("$ref").is_none()); |
| } |
|
|
| |
| #[test] |
| fn test_unresolved_ref_fallback() { |
| let mut schema = json!({ |
| "type": "object", |
| "properties": { |
| "external": { "$ref": "https://example.com/schemas/External.json" }, |
| "missing": { "$ref": "#/$defs/NonExistent" } |
| } |
| }); |
|
|
| clean_json_schema(&mut schema); |
|
|
| |
| let external = &schema["properties"]["external"]; |
| assert_eq!( |
| external["type"], "string", |
| "unresolved external $ref should fallback to string" |
| ); |
| assert!( |
| external["description"] |
| .as_str() |
| .unwrap() |
| .contains("Unresolved $ref"), |
| "description should contain unresolved $ref hint" |
| ); |
|
|
| |
| let missing = &schema["properties"]["missing"]; |
| assert_eq!(missing["type"], "string"); |
| assert!(missing["description"] |
| .as_str() |
| .unwrap() |
| .contains("NonExistent")); |
| } |
|
|
| |
| #[test] |
| fn test_deeply_nested_multi_level_defs() { |
| let mut schema = json!({ |
| "type": "object", |
| "$defs": { |
| "RootDef": { "type": "integer" } |
| }, |
| "properties": { |
| "level1": { |
| "type": "object", |
| "$defs": { |
| "Level1Def": { "type": "boolean" } |
| }, |
| "properties": { |
| "level2": { |
| "type": "object", |
| "$defs": { |
| "Level2Def": { "type": "number" } |
| }, |
| "properties": { |
| "useRoot": { "$ref": "#/$defs/RootDef" }, |
| "useLevel1": { "$ref": "#/$defs/Level1Def" }, |
| "useLevel2": { "$ref": "#/$defs/Level2Def" } |
| } |
| } |
| } |
| } |
| } |
| }); |
|
|
| clean_json_schema(&mut schema); |
|
|
| let level2_props = &schema["properties"]["level1"]["properties"]["level2"]["properties"]; |
|
|
| |
| assert_eq!( |
| level2_props["useRoot"]["type"], "integer", |
| "RootDef should resolve" |
| ); |
| assert_eq!( |
| level2_props["useLevel1"]["type"], "boolean", |
| "Level1Def should resolve" |
| ); |
| assert_eq!( |
| level2_props["useLevel2"]["type"], "number", |
| "Level2Def should resolve" |
| ); |
|
|
| |
| assert!(level2_props["useRoot"].get("$ref").is_none()); |
| assert!(level2_props["useLevel1"].get("$ref").is_none()); |
| assert!(level2_props["useLevel2"].get("$ref").is_none()); |
| } |
|
|
| |
| #[test] |
| fn test_non_standard_field_cleaning_and_healing() { |
| let mut schema = json!({ |
| "type": "array", |
| "items": { |
| "cornerRadius": { "type": "number" }, |
| "fillColor": { "type": "string" } |
| } |
| }); |
|
|
| clean_json_schema(&mut schema); |
|
|
| |
| let items = &schema["items"]; |
| assert_eq!(items["type"], "object", "Malformed items should be healed to type object"); |
| assert!(items.get("properties").is_some(), "Malformed items should have properties object"); |
| assert_eq!(items["properties"]["cornerRadius"]["type"], "number"); |
| assert_eq!(items["properties"]["fillColor"]["type"], "string"); |
| |
| |
| assert!(items.get("cornerRadius").is_none()); |
| assert!(items.get("fillColor").is_none()); |
| } |
|
|
| |
| #[test] |
| fn test_implicit_type_injection() { |
| let mut schema = json!({ |
| "properties": { |
| "values": { |
| "items": { |
| "cornerRadius": { "type": "number" } |
| } |
| } |
| } |
| }); |
|
|
| clean_json_schema(&mut schema); |
|
|
| |
| assert_eq!(schema["properties"]["values"]["type"], "array"); |
| |
| |
| let items = &schema["properties"]["values"]["items"]; |
| assert_eq!(items["type"], "object"); |
| assert!(items["properties"].get("cornerRadius").is_some()); |
| } |
|
|
| #[test] |
| fn test_gemini_strict_validation_injection() { |
| let mut schema = json!({ |
| "type": "object", |
| "properties": { |
| "patterns": { |
| "items": { |
| "properties": { |
| "type": { |
| "enum": ["A", "B"] |
| } |
| } |
| } |
| }, |
| "nested_props": { |
| "properties": { |
| "foo": { "type": "string" } |
| } |
| } |
| } |
| }); |
|
|
| clean_json_schema(&mut schema); |
|
|
| |
| let type_node = &schema["properties"]["patterns"]["items"]["properties"]["type"]; |
| assert_eq!(type_node["type"], "string"); |
| assert!(type_node.get("enum").is_some()); |
|
|
| |
| assert_eq!(schema["properties"]["nested_props"]["type"], "object"); |
|
|
| |
| assert_eq!(schema["properties"]["patterns"]["type"], "array"); |
| } |
| #[test] |
| fn test_malformed_items_as_properties() { |
| let mut schema = json!({ |
| "type": "object", |
| "properties": { |
| "config": { |
| "type": "object", |
| "items": { |
| "color": { "type": "string" }, |
| "size": { "type": "number" } |
| } |
| } |
| } |
| }); |
|
|
| clean_json_schema(&mut schema); |
|
|
| |
| let config = &schema["properties"]["config"]; |
| assert!(config.get("items").is_none()); |
| assert_eq!(config["properties"]["color"]["type"], "string"); |
| assert_eq!(config["properties"]["size"]["type"], "number"); |
| assert_eq!(config["type"], "object"); |
| } |
|
|
| #[test] |
| fn test_circular_ref_flattening() { |
| |
| let mut schema = json!({ |
| "$defs": { |
| "A": { |
| "type": "object", |
| "properties": { |
| "toB": { "$ref": "#/$defs/B" } |
| } |
| }, |
| "B": { |
| "type": "object", |
| "properties": { |
| "toA": { "$ref": "#/$defs/A" } |
| } |
| } |
| }, |
| "properties": { |
| "start": { "$ref": "#/$defs/A" } |
| } |
| }); |
|
|
| |
| |
| clean_json_schema(&mut schema); |
|
|
| |
| assert_eq!(schema["properties"]["start"]["type"], "object"); |
| assert!(schema["properties"]["start"]["properties"].get("toB").is_some()); |
| } |
|
|
| #[test] |
| fn test_any_of_best_branch_selection() { |
| let mut schema = json!({ |
| "anyOf": [ |
| { "type": "string" }, |
| { "type": "object", "properties": { "foo": { "type": "string" } } }, |
| { "type": "null" } |
| ] |
| }); |
|
|
| clean_json_schema(&mut schema); |
|
|
| |
| assert_eq!(schema["type"], "object"); |
| assert!(schema.get("properties").is_some()); |
| assert_eq!(schema["properties"]["foo"]["type"], "string"); |
| |
| |
| assert!(schema["description"].as_str().unwrap().contains("Accepts: string | object")); |
| } |
| } |
|
|