Skip to main content

swiftide_core/chat_completion/
tool_schema.rs

1use schemars::Schema;
2use serde_json::{Map, Value, json};
3use thiserror::Error;
4
5#[derive(Clone, Debug, PartialEq)]
6pub struct StrictToolParametersSchema {
7    document: Value,
8}
9
10#[derive(Debug, Error)]
11pub enum ToolSchemaError {
12    #[error("failed to serialize tool schema")]
13    SerializeSchema(#[from] serde_json::Error),
14    #[error("tool schema must be a JSON object")]
15    RootMustBeObject,
16    #[error("tool schema node at {path} must be a JSON object")]
17    NodeMustBeObject { path: String },
18    #[error("tool schema map at {path} must be a JSON object")]
19    NodeMapMustBeObject { path: String },
20    #[error("tool schema required must be an array at {path}")]
21    RequiredMustBeArray { path: String },
22    #[error(
23        "strict tool schemas do not support patternProperties at {path}; define explicit properties instead"
24    )]
25    PatternPropertiesUnsupported { path: String },
26    #[error(
27        "strict tool schemas do not support propertyNames at {path}; define explicit properties instead"
28    )]
29    PropertyNamesUnsupported { path: String },
30    #[error(
31        "strict tool schemas do not support open object schemas at {path}; define explicit properties instead"
32    )]
33    OpenObjectUnsupported { path: String },
34    #[error(
35        "strict tool schemas do not support schema-valued additionalProperties at {path}; define explicit properties instead"
36    )]
37    SchemaValuedAdditionalPropertiesUnsupported { path: String },
38    #[error("strict tool schemas do not support {kind}-valued additionalProperties at {path}")]
39    InvalidAdditionalProperties { path: String, kind: &'static str },
40    #[error("strict tool schemas do not support $ref siblings {keywords} at {path}")]
41    UnsupportedRefSiblingKeywords { path: String, keywords: String },
42}
43
44impl StrictToolParametersSchema {
45    pub(super) fn try_from_raw(schema: Option<&Schema>) -> Result<Self, ToolSchemaError> {
46        let raw = match schema {
47            Some(schema) => serde_json::to_value(schema)?,
48            None => json!({}),
49        };
50
51        let root = raw.as_object().ok_or(ToolSchemaError::RootMustBeObject)?;
52
53        Ok(Self {
54            document: Value::Object(parse_schema_object(root, &SchemaPath::root(), true)?),
55        })
56    }
57
58    pub fn into_json(self) -> Value {
59        self.document
60    }
61
62    pub fn as_json(&self) -> &Value {
63        &self.document
64    }
65}
66
67fn parse_schema_value(value: &Value, path: &SchemaPath) -> Result<Value, ToolSchemaError> {
68    let object = value
69        .as_object()
70        .ok_or_else(|| ToolSchemaError::NodeMustBeObject {
71            path: path.to_string(),
72        })?;
73
74    Ok(Value::Object(parse_schema_object(object, path, false)?))
75}
76
77fn parse_schema_object(
78    schema: &Map<String, Value>,
79    path: &SchemaPath,
80    force_object: bool,
81) -> Result<Map<String, Value>, ToolSchemaError> {
82    let schema = normalize_schema_object(schema, path)?;
83
84    if force_object || schema_is_object(&schema) {
85        parse_object_schema(&schema, path)
86    } else {
87        parse_non_object_schema(&schema, path)
88    }
89}
90
91fn normalize_schema_object(
92    schema: &Map<String, Value>,
93    path: &SchemaPath,
94) -> Result<Map<String, Value>, ToolSchemaError> {
95    let mut normalized = schema.clone();
96    rewrite_nullable_type_union(&mut normalized);
97    rewrite_nullable_one_of(&mut normalized);
98    strip_ref_annotation_siblings(&mut normalized, path)?;
99    Ok(normalized)
100}
101
102fn rewrite_nullable_type_union(schema: &mut Map<String, Value>) {
103    let Some(entries) = schema.get("type").and_then(Value::as_array) else {
104        return;
105    };
106
107    let Some(non_null_type) = nullable_type_union(entries).map(str::to_owned) else {
108        return;
109    };
110
111    let mut non_null_branch = schema.clone();
112    non_null_branch.insert("type".to_string(), Value::String(non_null_type));
113    let annotations = extract_schema_annotations(schema);
114
115    for key in schema_annotation_keys() {
116        non_null_branch.remove(*key);
117    }
118
119    schema.clear();
120    schema.extend(annotations);
121    schema.insert(
122        "anyOf".to_string(),
123        Value::Array(vec![
124            Value::Object(non_null_branch),
125            json!({ "type": "null" }),
126        ]),
127    );
128}
129
130fn rewrite_nullable_one_of(schema: &mut Map<String, Value>) {
131    let Some(entries) = schema.get("oneOf").and_then(Value::as_array).cloned() else {
132        return;
133    };
134
135    if is_nullable_union(&entries) {
136        schema.remove("oneOf");
137        schema.insert("anyOf".to_string(), Value::Array(entries));
138    }
139}
140
141fn is_nullable_union(entries: &[Value]) -> bool {
142    entries.len() == 2 && entries.iter().any(is_null_schema)
143}
144
145fn nullable_type_union(entries: &[Value]) -> Option<&str> {
146    if entries.len() != 2 {
147        return None;
148    }
149
150    let mut non_null = None;
151
152    for entry in entries {
153        let kind = entry.as_str()?;
154        if kind == "null" {
155            continue;
156        }
157
158        if non_null.is_some() {
159            return None;
160        }
161
162        non_null = Some(kind);
163    }
164
165    non_null
166}
167
168fn is_null_schema(value: &Value) -> bool {
169    matches!(
170        value,
171        Value::Object(object) if matches!(object.get("type"), Some(Value::String(kind)) if kind == "null")
172    )
173}
174
175fn extract_schema_annotations(schema: &Map<String, Value>) -> Map<String, Value> {
176    schema_annotation_keys()
177        .iter()
178        .filter_map(|key| {
179            schema
180                .get(*key)
181                .cloned()
182                .map(|value| ((*key).to_string(), value))
183        })
184        .collect()
185}
186
187fn schema_annotation_keys() -> &'static [&'static str] {
188    &[
189        "description",
190        "title",
191        "default",
192        "examples",
193        "deprecated",
194        "readOnly",
195        "writeOnly",
196    ]
197}
198
199fn strip_ref_annotation_siblings(
200    schema: &mut Map<String, Value>,
201    path: &SchemaPath,
202) -> Result<(), ToolSchemaError> {
203    const SAFE_REF_ANNOTATIONS: &[&str] = &[
204        "description",
205        "title",
206        "default",
207        "examples",
208        "deprecated",
209        "readOnly",
210        "writeOnly",
211    ];
212
213    if !schema.contains_key("$ref") {
214        return Ok(());
215    }
216
217    let mut unsupported = Vec::new();
218    let sibling_keys = schema
219        .keys()
220        .filter(|key| key.as_str() != "$ref")
221        .cloned()
222        .collect::<Vec<_>>();
223
224    for key in sibling_keys {
225        if SAFE_REF_ANNOTATIONS.contains(&key.as_str()) {
226            schema.remove(&key);
227        } else {
228            unsupported.push(key);
229        }
230    }
231
232    if unsupported.is_empty() {
233        Ok(())
234    } else {
235        Err(ToolSchemaError::UnsupportedRefSiblingKeywords {
236            path: path.to_string(),
237            keywords: unsupported.join(", "),
238        })
239    }
240}
241
242fn parse_object_schema(
243    schema: &Map<String, Value>,
244    path: &SchemaPath,
245) -> Result<Map<String, Value>, ToolSchemaError> {
246    if schema.get("patternProperties").is_some() {
247        return Err(ToolSchemaError::PatternPropertiesUnsupported {
248            path: path.to_string(),
249        });
250    }
251
252    if schema.get("propertyNames").is_some() {
253        return Err(ToolSchemaError::PropertyNamesUnsupported {
254            path: path.to_string(),
255        });
256    }
257
258    match schema.get("additionalProperties") {
259        Some(Value::Bool(true)) => {
260            return Err(ToolSchemaError::OpenObjectUnsupported {
261                path: path.to_string(),
262            });
263        }
264        Some(Value::Object(_)) => {
265            return Err(
266                ToolSchemaError::SchemaValuedAdditionalPropertiesUnsupported {
267                    path: path.to_string(),
268                },
269            );
270        }
271        Some(Value::Array(_)) => {
272            return Err(ToolSchemaError::InvalidAdditionalProperties {
273                path: path.to_string(),
274                kind: "array",
275            });
276        }
277        Some(Value::Null) => {
278            return Err(ToolSchemaError::InvalidAdditionalProperties {
279                path: path.to_string(),
280                kind: "null",
281            });
282        }
283        Some(Value::String(_) | Value::Number(_)) => {
284            return Err(ToolSchemaError::InvalidAdditionalProperties {
285                path: path.to_string(),
286                kind: "scalar",
287            });
288        }
289        Some(Value::Bool(false)) | None => {}
290    }
291
292    let mut parsed = schema.clone();
293    parsed.insert("type".to_string(), Value::String("object".to_string()));
294    parsed.insert("additionalProperties".to_string(), Value::Bool(false));
295    parsed.insert(
296        "properties".to_string(),
297        Value::Object(parse_schema_map(
298            schema.get("properties"),
299            &path.with_key("properties"),
300        )?),
301    );
302
303    if let Some(required) = schema.get("required")
304        && !required.is_array()
305    {
306        return Err(ToolSchemaError::RequiredMustBeArray {
307            path: path.with_key("required").to_string(),
308        });
309    }
310
311    recurse_schema_children(schema, &mut parsed, path)?;
312    Ok(parsed)
313}
314
315fn parse_non_object_schema(
316    schema: &Map<String, Value>,
317    path: &SchemaPath,
318) -> Result<Map<String, Value>, ToolSchemaError> {
319    let mut parsed = schema.clone();
320    recurse_schema_children(schema, &mut parsed, path)?;
321    Ok(parsed)
322}
323
324fn recurse_schema_children(
325    source: &Map<String, Value>,
326    target: &mut Map<String, Value>,
327    path: &SchemaPath,
328) -> Result<(), ToolSchemaError> {
329    for key in ["items", "contains", "if", "then", "else", "not"] {
330        if let Some(schema) = source.get(key) {
331            target.insert(
332                key.to_string(),
333                parse_schema_value(schema, &path.with_key(key))?,
334            );
335        }
336    }
337
338    for key in ["anyOf", "oneOf", "allOf", "prefixItems"] {
339        if let Some(entries) = source.get(key).and_then(Value::as_array) {
340            target.insert(
341                key.to_string(),
342                Value::Array(
343                    entries
344                        .iter()
345                        .enumerate()
346                        .map(|(index, schema)| {
347                            parse_schema_value(schema, &path.with_index(key, index))
348                        })
349                        .collect::<Result<Vec<_>, _>>()?,
350                ),
351            );
352        }
353    }
354
355    for key in ["properties", "$defs", "definitions", "dependentSchemas"] {
356        if let Some(entries) = source.get(key) {
357            target.insert(
358                key.to_string(),
359                Value::Object(parse_schema_map(Some(entries), &path.with_key(key))?),
360            );
361        }
362    }
363
364    Ok(())
365}
366
367fn parse_schema_map(
368    value: Option<&Value>,
369    path: &SchemaPath,
370) -> Result<Map<String, Value>, ToolSchemaError> {
371    let Some(value) = value else {
372        return Ok(Map::new());
373    };
374
375    let entries = value
376        .as_object()
377        .ok_or_else(|| ToolSchemaError::NodeMapMustBeObject {
378            path: path.to_string(),
379        })?;
380
381    let mut parsed = Map::new();
382    for (key, schema) in entries {
383        parsed.insert(
384            key.clone(),
385            parse_schema_value(schema, &path.with_key(key))?,
386        );
387    }
388
389    Ok(parsed)
390}
391
392fn schema_is_object(schema: &Map<String, Value>) -> bool {
393    type_includes_object(schema.get("type"))
394        || schema.contains_key("properties")
395        || schema.contains_key("additionalProperties")
396        || schema.contains_key("patternProperties")
397        || schema.contains_key("propertyNames")
398}
399
400fn type_includes_object(value: Option<&Value>) -> bool {
401    match value {
402        Some(Value::String(kind)) => kind == "object",
403        Some(Value::Array(kinds)) => kinds
404            .iter()
405            .filter_map(Value::as_str)
406            .any(|kind| kind == "object"),
407        _ => false,
408    }
409}
410
411#[derive(Clone, Debug)]
412pub(super) struct SchemaPath(Vec<String>);
413
414impl SchemaPath {
415    fn root() -> Self {
416        Self(vec!["$".to_string()])
417    }
418
419    fn with_key(&self, key: impl Into<String>) -> Self {
420        let mut path = self.0.clone();
421        path.push(key.into());
422        Self(path)
423    }
424
425    fn with_index(&self, key: impl Into<String>, index: usize) -> Self {
426        let mut path = self.0.clone();
427        path.push(key.into());
428        path.push(index.to_string());
429        Self(path)
430    }
431}
432
433impl std::fmt::Display for SchemaPath {
434    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
435        write!(f, "{}", self.0.join("."))
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
444    #[serde(deny_unknown_fields)]
445    struct NestedCommentArgs {
446        request: NestedCommentRequest,
447    }
448
449    #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
450    #[serde(deny_unknown_fields)]
451    struct NestedCommentRequest {
452        #[serde(default, skip_serializing_if = "Option::is_none")]
453        body: Option<String>,
454        #[serde(default, skip_serializing_if = "Option::is_none")]
455        text: Option<String>,
456        #[serde(default, skip_serializing_if = "Option::is_none")]
457        page_id: Option<String>,
458        #[serde(default, skip_serializing_if = "Option::is_none")]
459        block_id: Option<String>,
460        #[serde(default, skip_serializing_if = "Option::is_none")]
461        discussion_id: Option<String>,
462    }
463
464    #[derive(serde::Serialize, serde::Deserialize)]
465    #[serde(transparent)]
466    struct FreeformObject(serde_json::Map<String, Value>);
467
468    impl schemars::JsonSchema for FreeformObject {
469        fn schema_name() -> std::borrow::Cow<'static, str> {
470            "FreeformObject".into()
471        }
472
473        fn json_schema(_generator: &mut schemars::SchemaGenerator) -> Schema {
474            serde_json::from_value(json!({
475                "type": "object",
476                "additionalProperties": true
477            }))
478            .expect("freeform object schema should serialize")
479        }
480    }
481
482    #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
483    #[serde(deny_unknown_fields)]
484    struct CreateViewArgs {
485        request: CreateViewRequest,
486    }
487
488    #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
489    #[serde(deny_unknown_fields)]
490    struct CreateViewRequest {
491        body: FreeformObject,
492    }
493
494    #[test]
495    fn strict_tool_schema_rejects_nested_freeform_object_wrappers() {
496        let error =
497            StrictToolParametersSchema::try_from_raw(Some(&schemars::schema_for!(CreateViewArgs)))
498                .expect_err("freeform object should be rejected in strict mode");
499
500        let message = error.to_string();
501        assert!(message.contains("strict tool schemas do not support open object schemas"));
502        assert!(message.contains("FreeformObject"));
503    }
504
505    #[test]
506    fn strict_tool_schema_rewrites_nullable_type_unions_to_any_of() {
507        let schema: Schema = serde_json::from_value(json!({
508            "type": "object",
509            "properties": {
510                "body": {
511                    "type": ["string", "null"]
512                }
513            },
514            "required": ["body"]
515        }))
516        .expect("schema should deserialize");
517
518        let rendered = StrictToolParametersSchema::try_from_raw(Some(&schema))
519            .unwrap()
520            .into_json();
521
522        let body = &rendered["properties"]["body"];
523        assert!(body.get("type").is_none());
524        assert!(body.get("oneOf").is_none());
525        assert_eq!(
526            body["anyOf"],
527            Value::Array(vec![json!({ "type": "string" }), json!({ "type": "null" })])
528        );
529    }
530
531    #[test]
532    fn strict_tool_schema_rewrites_nullable_one_of_to_any_of() {
533        let schema: Schema = serde_json::from_value(json!({
534            "type": "object",
535            "properties": {
536                "body": {
537                    "oneOf": [
538                        { "type": "string" },
539                        { "type": "null" }
540                    ]
541                }
542            },
543            "required": ["body"]
544        }))
545        .expect("schema should deserialize");
546
547        let rendered = StrictToolParametersSchema::try_from_raw(Some(&schema))
548            .unwrap()
549            .into_json();
550
551        let body = &rendered["properties"]["body"];
552        assert!(body.get("type").is_none());
553        assert!(body.get("oneOf").is_none());
554        assert_eq!(
555            body["anyOf"],
556            Value::Array(vec![json!({ "type": "string" }), json!({ "type": "null" })])
557        );
558    }
559
560    #[test]
561    fn strict_tool_schema_strips_ref_annotation_siblings() {
562        let schema: Schema = serde_json::from_value(json!({
563            "type": "object",
564            "properties": {
565                "request": {
566                    "$ref": "#/$defs/NestedCommentRequest",
567                    "description": "A nested payload"
568                }
569            },
570            "required": ["request"],
571            "$defs": {
572                "NestedCommentRequest": {
573                    "type": "object",
574                    "properties": {
575                        "body": { "type": "string" }
576                    },
577                    "required": ["body"]
578                }
579            }
580        }))
581        .expect("schema should deserialize");
582
583        let rendered = StrictToolParametersSchema::try_from_raw(Some(&schema))
584            .unwrap()
585            .into_json();
586
587        assert_eq!(
588            rendered["properties"]["request"],
589            json!({ "$ref": "#/$defs/NestedCommentRequest" })
590        );
591    }
592
593    #[test]
594    fn strict_tool_schema_preserves_nullable_numeric_constraints_on_the_non_null_branch() {
595        let schema: Schema = serde_json::from_value(json!({
596            "$schema": "https://json-schema.org/draft/2020-12/schema",
597            "type": "object",
598            "properties": {
599                "page_size": {
600                    "type": ["integer", "null"],
601                    "format": "uint",
602                    "minimum": 0
603                }
604            },
605            "required": ["page_size"]
606        }))
607        .expect("schema should deserialize");
608
609        let rendered = StrictToolParametersSchema::try_from_raw(Some(&schema))
610            .unwrap()
611            .into_json();
612
613        assert_eq!(
614            rendered.get("$schema"),
615            Some(&json!("https://json-schema.org/draft/2020-12/schema"))
616        );
617        let page_size = &rendered["properties"]["page_size"];
618        assert!(page_size.get("format").is_none());
619        assert!(page_size.get("minimum").is_none());
620        assert_eq!(
621            page_size["anyOf"],
622            Value::Array(vec![
623                json!({ "type": "integer", "format": "uint", "minimum": 0 }),
624                json!({ "type": "null" })
625            ])
626        );
627    }
628
629    #[test]
630    fn strict_tool_schema_moves_nullable_array_constraints_into_the_array_branch() {
631        let schema: Schema = serde_json::from_value(json!({
632            "type": "object",
633            "properties": {
634                "children": {
635                    "type": ["array", "null"],
636                    "items": { "type": "string" }
637                }
638            },
639            "required": ["children"]
640        }))
641        .expect("schema should deserialize");
642
643        let rendered = StrictToolParametersSchema::try_from_raw(Some(&schema))
644            .unwrap()
645            .into_json();
646
647        let children = &rendered["properties"]["children"];
648        assert!(children.get("items").is_none());
649        assert_eq!(
650            children["anyOf"],
651            Value::Array(vec![
652                json!({ "type": "array", "items": { "type": "string" } }),
653                json!({ "type": "null" })
654            ])
655        );
656    }
657
658    #[test]
659    fn strict_tool_schema_preserves_optional_nested_fields_before_provider_shaping() {
660        let schema = StrictToolParametersSchema::try_from_raw(Some(&schemars::schema_for!(
661            NestedCommentArgs
662        )))
663        .unwrap();
664
665        let rendered = schema.into_json();
666        let nested_ref = rendered["properties"]["request"]["$ref"]
667            .as_str()
668            .expect("nested request should be referenced");
669        let nested_name = nested_ref
670            .rsplit('/')
671            .next()
672            .expect("nested request ref name");
673
674        assert_eq!(rendered["additionalProperties"], Value::Bool(false));
675        assert!(
676            rendered["$defs"][nested_name].get("required").is_none(),
677            "provider-neutral parsing should not force optional nested fields into required"
678        );
679    }
680}