Skip to main content

ferrin_schema/
transform.rs

1//! Provider-oriented JSON Schema rewrites.
2
3use serde_json::Map;
4use serde_json::Value;
5use serde_json::json;
6
7use crate::transform_refs::child_path;
8use crate::transform_refs::rewrite_local_references;
9use crate::transform_refs::visit_children;
10
11use crate::SchemaError;
12
13/// A rewrite applied to a JSON Schema before it is sent to a provider.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15#[non_exhaustive]
16pub enum SchemaTransform {
17    /// Set `additionalProperties: false` on every object schema.
18    ///
19    /// Applied by default to schemas derived from Rust types, so that models
20    /// cannot invent properties the type does not declare.
21    AdditionalPropertiesFalse,
22    /// Remove `propertyNames` keywords everywhere.
23    RemovePropertyNames,
24    /// OpenAI strict mode: `additionalProperties: false`, no `propertyNames`,
25    /// every property required, previously optional properties nullable.
26    OpenAiStrict,
27}
28
29impl SchemaTransform {
30    /// The OpenAI strict-mode transform.
31    #[must_use]
32    pub fn openai_strict() -> Self {
33        Self::OpenAiStrict
34    }
35
36    /// The `additionalProperties: false` transform.
37    #[must_use]
38    pub fn additional_properties_false() -> Self {
39        Self::AdditionalPropertiesFalse
40    }
41
42    /// Applies the transform in place, leaving the input unchanged on error.
43    ///
44    /// # Errors
45    ///
46    /// Returns [`SchemaError::UnsupportedTransform`] for dictionaries that
47    /// OpenAI strict mode cannot represent.
48    pub fn apply(self, schema: &mut Value) -> Result<(), SchemaError> {
49        match self {
50            Self::AdditionalPropertiesFalse => add_additional_properties_false(schema),
51            Self::RemovePropertyNames => remove_property_names(schema),
52            Self::OpenAiStrict => return to_openai_strict(schema),
53        }
54        Ok(())
55    }
56
57    /// Applies the transform to an owned schema and returns it.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`SchemaError::UnsupportedTransform`] for dictionaries that
62    /// OpenAI strict mode cannot represent.
63    pub fn applied(self, mut schema: Value) -> Result<Value, SchemaError> {
64        self.apply(&mut schema)?;
65        Ok(schema)
66    }
67}
68
69/// Sets `additionalProperties: false` on every object schema that does not
70/// already declare a schema for additional properties.
71///
72/// Objects are recognized by `type: "object"` (or a type array containing
73/// `object`). Recurses into `properties`, `additionalProperties`, `items`,
74/// `anyOf`, `allOf`, `oneOf` and `definitions`/`$defs`.
75pub fn add_additional_properties_false(schema: &mut Value) {
76    let Value::Object(obj) = schema else { return };
77    if type_includes(obj, "object") {
78        match obj.get_mut("additionalProperties") {
79            Some(nested @ Value::Object(_)) => add_additional_properties_false(nested),
80            _ => {
81                obj.insert("additionalProperties".to_owned(), Value::Bool(false));
82            }
83        }
84        if let Some(Value::Object(properties)) = obj.get_mut("properties") {
85            for property in properties.values_mut() {
86                add_additional_properties_false(property);
87            }
88        }
89    }
90    for_each_subschema(obj, &mut add_additional_properties_false);
91}
92
93/// Removes `propertyNames` from every schema.
94pub fn remove_property_names(schema: &mut Value) {
95    let Value::Object(obj) = schema else { return };
96    obj.remove("propertyNames");
97    if let Some(Value::Object(properties)) = obj.get_mut("properties") {
98        for property in properties.values_mut() {
99            remove_property_names(property);
100        }
101    }
102    if let Some(nested @ Value::Object(_)) = obj.get_mut("additionalProperties") {
103        remove_property_names(nested);
104    }
105    for_each_subschema(obj, &mut remove_property_names);
106}
107
108/// Rewrites a schema for OpenAI strict mode.
109///
110/// Every object gets `additionalProperties: false`, `propertyNames` is
111/// removed, every property is listed in `required`, and properties that were
112/// optional become nullable (the complete schema is wrapped in
113/// `anyOf: [original, {type: null}]`). Objects are recognized by `type` or by
114/// the presence of `properties`.
115///
116/// # Errors
117///
118/// Returns [`SchemaError::UnsupportedTransform`] for schema-valued or
119/// explicitly true `additionalProperties`, or `patternProperties`. The input
120/// is unchanged on error; arbitrary dictionary values are never discarded.
121pub fn to_openai_strict(schema: &mut Value) -> Result<(), SchemaError> {
122    validate_strict_maps(schema)?;
123    let mut moves = Vec::new();
124    rewrite_openai_strict(schema, "", &mut moves);
125    rewrite_local_references(schema, &moves);
126    Ok(())
127}
128
129fn validate_strict_maps(schema: &Value) -> Result<(), SchemaError> {
130    let Value::Object(obj) = schema else {
131        return Ok(());
132    };
133    for keyword in ["additionalProperties", "patternProperties"] {
134        let unsupported = matches!(obj.get(keyword), Some(Value::Object(_) | Value::Bool(true)));
135        if unsupported {
136            return Err(SchemaError::UnsupportedTransform {
137                transform: "openai strict",
138                keyword,
139            });
140        }
141    }
142    for key in [
143        "properties",
144        "definitions",
145        "$defs",
146        "dependentSchemas",
147        "dependencies",
148    ] {
149        if let Some(Value::Object(map)) = obj.get(key) {
150            for child in map.values() {
151                validate_strict_maps(child)?;
152            }
153        }
154    }
155    for key in [
156        "items",
157        "additionalItems",
158        "anyOf",
159        "allOf",
160        "oneOf",
161        "prefixItems",
162        "not",
163        "if",
164        "then",
165        "else",
166        "contains",
167        "unevaluatedItems",
168        "unevaluatedProperties",
169    ] {
170        if let Some(child) = obj.get(key) {
171            match child {
172                Value::Array(children) => {
173                    for child in children {
174                        validate_strict_maps(child)?;
175                    }
176                }
177                child => validate_strict_maps(child)?,
178            }
179        }
180    }
181    Ok(())
182}
183
184fn rewrite_openai_strict(schema: &mut Value, path: &str, moves: &mut Vec<String>) {
185    let Value::Object(obj) = schema else { return };
186    obj.remove("propertyNames");
187    let is_object = match obj.get("type") {
188        Some(Value::String(kind)) => kind == "object",
189        Some(Value::Array(kinds)) => kinds.iter().any(|kind| kind == "object"),
190        _ => obj.contains_key("properties"),
191    };
192    if is_object {
193        let required: Vec<String> = obj
194            .get("required")
195            .and_then(Value::as_array)
196            .map(|items| {
197                items
198                    .iter()
199                    .filter_map(Value::as_str)
200                    .map(str::to_owned)
201                    .collect()
202            })
203            .unwrap_or_default();
204        if let Some(Value::Object(properties)) = obj.get_mut("properties") {
205            let names: Vec<String> = properties.keys().cloned().collect();
206            for (name, property) in properties.iter_mut() {
207                if !required.contains(name) {
208                    moves.push(child_path(&child_path(path, "properties"), name));
209                    make_nullable(property);
210                }
211            }
212            obj.insert("required".to_owned(), json!(names));
213        }
214        obj.insert("additionalProperties".to_owned(), Value::Bool(false));
215    }
216    visit_children(obj, path, &mut |child, child_path| {
217        rewrite_openai_strict(child, child_path, moves);
218    });
219}
220
221/// Makes a property schema accept `null`.
222fn make_nullable(property: &mut Value) {
223    // Null must bypass every constraint, including enum, const, and not.
224    // Widening only `type` would leave those constraints rejecting null.
225    let original = std::mem::take(property);
226    *property = json!({ "anyOf": [original, { "type": "null" }] });
227}
228
229fn type_includes(obj: &Map<String, Value>, kind: &str) -> bool {
230    match obj.get("type") {
231        Some(Value::String(actual)) => actual == kind,
232        Some(Value::Array(kinds)) => kinds.iter().any(|actual| actual == kind),
233        _ => false,
234    }
235}
236
237/// Visits `items`, `anyOf`, `allOf`, `oneOf`, `definitions` and `$defs`.
238fn for_each_subschema(obj: &mut Map<String, Value>, visit: &mut dyn FnMut(&mut Value)) {
239    if let Some(items) = obj.get_mut("items") {
240        match items {
241            Value::Array(list) => list.iter_mut().for_each(&mut *visit),
242            other => visit(other),
243        }
244    }
245    for key in ["anyOf", "allOf", "oneOf"] {
246        if let Some(Value::Array(list)) = obj.get_mut(key) {
247            list.iter_mut().for_each(&mut *visit);
248        }
249    }
250    for key in ["definitions", "$defs"] {
251        if let Some(Value::Object(map)) = obj.get_mut(key) {
252            map.values_mut().for_each(&mut *visit);
253        }
254    }
255}