Skip to main content

vtcode_utility_tool_specs/
json_schema.rs

1use serde::{Deserialize, Serialize};
2use serde_json::{Map, Value, json};
3
4pub type JsonSchema = Value;
5
6#[derive(Clone, Debug, Serialize, Deserialize)]
7#[serde(untagged)]
8pub enum AdditionalProperties {
9    Boolean(bool),
10    Schema(Box<JsonSchema>),
11}
12
13impl From<bool> for AdditionalProperties {
14    fn from(value: bool) -> Self {
15        Self::Boolean(value)
16    }
17}
18
19#[must_use]
20pub fn parse_tool_input_schema(value: &Value) -> JsonSchema {
21    let mut schema = value.clone();
22    sanitize_json_schema(&mut schema);
23    schema
24}
25
26fn sanitize_json_schema(value: &mut Value) {
27    match value {
28        Value::Bool(_) => {
29            *value = json!({ "type": "string" });
30        }
31        Value::Object(map) => sanitize_schema_object(map),
32        Value::Array(items) => {
33            for item in items {
34                sanitize_json_schema(item);
35            }
36        }
37        Value::Null | Value::Number(_) | Value::String(_) => {}
38    }
39}
40
41fn sanitize_schema_object(map: &mut Map<String, Value>) {
42    if let Some(properties) = map.get_mut("properties").and_then(Value::as_object_mut) {
43        for schema in properties.values_mut() {
44            sanitize_json_schema(schema);
45        }
46    }
47
48    if let Some(items) = map.get_mut("items") {
49        sanitize_json_schema(items);
50    }
51
52    if let Some(prefix_items) = map.get_mut("prefixItems") {
53        sanitize_json_schema(prefix_items);
54    }
55
56    if let Some(additional_properties) = map.get_mut("additionalProperties")
57        && !matches!(additional_properties, Value::Bool(_))
58    {
59        sanitize_json_schema(additional_properties);
60    }
61
62    if let Some(any_of) = map.get_mut("anyOf") {
63        sanitize_json_schema(any_of);
64    }
65
66    if let Some(const_value) = map.remove("const") {
67        map.insert("enum".to_string(), Value::Array(vec![const_value]));
68    }
69
70    let mut schema_types = normalized_schema_types(map);
71    if schema_types.is_empty() && map.contains_key("anyOf") {
72        return;
73    }
74
75    if schema_types.is_empty() {
76        if map.contains_key("properties")
77            || map.contains_key("required")
78            || map.contains_key("additionalProperties")
79        {
80            schema_types.push("object");
81        } else if map.contains_key("items") || map.contains_key("prefixItems") {
82            schema_types.push("array");
83        } else if map.contains_key("enum") || map.contains_key("format") {
84            schema_types.push("string");
85        } else if map.contains_key("minimum")
86            || map.contains_key("maximum")
87            || map.contains_key("exclusiveMinimum")
88            || map.contains_key("exclusiveMaximum")
89            || map.contains_key("multipleOf")
90        {
91            schema_types.push("number");
92        } else {
93            schema_types.push("string");
94        }
95    }
96
97    write_schema_types(map, &schema_types);
98    ensure_default_children_for_schema_types(map, &schema_types);
99}
100
101fn normalized_schema_types(map: &Map<String, Value>) -> Vec<&'static str> {
102    let Some(schema_type) = map.get("type") else {
103        return Vec::new();
104    };
105
106    match schema_type {
107        Value::String(schema_type) => schema_type_from_str(schema_type).into_iter().collect(),
108        Value::Array(schema_types) => schema_types
109            .iter()
110            .filter_map(Value::as_str)
111            .filter_map(schema_type_from_str)
112            .collect(),
113        _ => Vec::new(),
114    }
115}
116
117fn write_schema_types(map: &mut Map<String, Value>, schema_types: &[&'static str]) {
118    match schema_types {
119        [] => {
120            map.remove("type");
121        }
122        [schema_type] => {
123            map.insert(
124                "type".to_string(),
125                Value::String((*schema_type).to_string()),
126            );
127        }
128        _ => {
129            map.insert(
130                "type".to_string(),
131                Value::Array(
132                    schema_types
133                        .iter()
134                        .map(|schema_type| Value::String((*schema_type).to_string()))
135                        .collect(),
136                ),
137            );
138        }
139    }
140}
141
142fn ensure_default_children_for_schema_types(map: &mut Map<String, Value>, schema_types: &[&str]) {
143    if schema_types.contains(&"object") && !map.contains_key("properties") {
144        map.insert("properties".to_string(), Value::Object(Map::new()));
145    }
146
147    if schema_types.contains(&"array") && !map.contains_key("items") {
148        map.insert("items".to_string(), json!({ "type": "string" }));
149    }
150}
151
152fn schema_type_from_str(schema_type: &str) -> Option<&'static str> {
153    match schema_type {
154        "string" => Some("string"),
155        "number" => Some("number"),
156        "integer" => Some("integer"),
157        "boolean" => Some("boolean"),
158        "object" => Some("object"),
159        "array" => Some("array"),
160        "null" => Some("null"),
161        _ => None,
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::parse_tool_input_schema;
168    use serde_json::{Value, json};
169
170    #[test]
171    fn parse_tool_input_schema_preserves_schema_field_names() {
172        let schema = parse_tool_input_schema(&json!({
173            "type": "object",
174            "properties": {
175                "input": {"type": "string"}
176            },
177            "additionalProperties": false,
178            "anyOf": [
179                {"required": ["input"]},
180                {"required": ["patch"]}
181            ]
182        }));
183
184        let serialized = serde_json::to_value(&schema).expect("serialize schema");
185        assert_eq!(serialized["additionalProperties"], Value::Bool(false));
186        assert!(serialized["anyOf"].is_array());
187        assert!(serialized.get("additional_properties").is_none());
188        assert!(serialized.get("any_of").is_none());
189    }
190
191    #[test]
192    fn parse_tool_input_schema_parses_object_additional_properties_schema() {
193        let schema = parse_tool_input_schema(&json!({
194            "type": "object",
195            "additionalProperties": {
196                "type": "string",
197                "description": "value"
198            }
199        }));
200
201        assert_eq!(schema["type"], "object");
202        assert_eq!(schema["additionalProperties"]["type"], "string");
203        assert_eq!(schema["additionalProperties"]["description"], "value");
204    }
205
206    #[test]
207    fn parse_tool_input_schema_preserves_nested_any_of_and_nullable_type_unions() {
208        let schema = parse_tool_input_schema(&json!({
209            "type": "object",
210            "properties": {
211                "open": {
212                    "anyOf": [
213                        {
214                            "type": "array",
215                            "items": {
216                                "type": "object",
217                                "properties": {
218                                    "ref_id": {"type": "string"},
219                                    "lineno": {"type": ["integer", "null"]}
220                                },
221                                "required": ["ref_id"],
222                                "additionalProperties": false
223                            }
224                        },
225                        {"type": "null"}
226                    ]
227                },
228                "message": {"type": ["string", "null"]}
229            },
230            "additionalProperties": false
231        }));
232
233        let variants = schema["properties"]["open"]["anyOf"]
234            .as_array()
235            .expect("open anyOf");
236        assert_eq!(variants.len(), 2);
237        assert_eq!(variants[0]["type"], "array");
238        assert_eq!(variants[0]["items"]["type"], "object");
239        assert_eq!(
240            variants[0]["items"]["properties"]["lineno"]["type"],
241            json!(["integer", "null"])
242        );
243        assert_eq!(
244            schema["properties"]["message"]["type"],
245            json!(["string", "null"])
246        );
247    }
248
249    #[test]
250    fn parse_tool_input_schema_preserves_integer_and_string_enums() {
251        let schema = parse_tool_input_schema(&json!({
252            "type": "object",
253            "properties": {
254                "page": {"type": "integer"},
255                "response_length": {
256                    "type": "string",
257                    "enum": ["short", "medium", "long"]
258                },
259                "kind": {
260                    "type": "const",
261                    "const": "tagged"
262                }
263            }
264        }));
265
266        assert_eq!(schema["properties"]["page"]["type"], "integer");
267        assert_eq!(
268            schema["properties"]["response_length"]["enum"],
269            json!(["short", "medium", "long"])
270        );
271        assert_eq!(schema["properties"]["kind"]["type"], "string");
272        assert_eq!(schema["properties"]["kind"]["enum"], json!(["tagged"]));
273    }
274}