Skip to main content

yapi_ai/
schema.rs

1//! Constrained sampling for tools: strict JSON-schema parameters, and OpenAI
2//! grammar-constrained custom tools.
3
4use std::collections::HashMap;
5
6use serde_json::{Map, Value};
7use yapi_types::message::{Message, ToolDeclaration};
8
9use crate::http::is_truthy;
10use crate::transcript::declared_tools;
11use crate::validation::schema_types;
12
13/// Keywords no strict mode accepts.
14const UNSUPPORTED_KEYS: &[&str] = &[
15    "$ref",
16    "$defs",
17    "definitions",
18    "allOf",
19    "oneOf",
20    "patternProperties",
21    "dependentSchemas",
22    "dependencies",
23    "unevaluatedProperties",
24    "propertyNames",
25    "contains",
26    "prefixItems",
27    "not",
28    "if",
29    "then",
30    "else",
31];
32
33/// A provider's extra keyword restrictions: `(key, value)` is unsupported.
34pub type KeywordCheck = fn(&str, &Value) -> bool;
35
36/// The schema rewritten for strict mode: every property required, optional ones
37/// nullable, no additional properties. `Err` names the unsupported construct.
38pub fn make_strict(schema: &Value, check: Option<KeywordCheck>) -> Result<Value, String> {
39    let mut schema = schema.clone();
40    if !schema.is_object() {
41        return Err("root schema must have type object".into());
42    }
43    make_node_strict(&mut schema, check)?;
44    if schema.get("type") != Some(&Value::from("object")) {
45        return Err("root schema must have type object".into());
46    }
47    Ok(schema)
48}
49
50fn is_structured(schema: &Value) -> bool {
51    let Some(object) = schema.as_object() else {
52        return false;
53    };
54    let types = schema_types(schema);
55    types.contains(&"object")
56        || types.contains(&"array")
57        || object.contains_key("properties")
58        || object.contains_key("items")
59}
60
61fn allows_null(schema: &Value) -> bool {
62    let Some(object) = schema.as_object() else {
63        return false;
64    };
65    if schema_types(schema).contains(&"null")
66        || object.get("const") == Some(&Value::Null)
67        || object
68            .get("enum")
69            .and_then(Value::as_array)
70            .is_some_and(|values| values.contains(&Value::Null))
71    {
72        return true;
73    }
74    object
75        .get("anyOf")
76        .and_then(Value::as_array)
77        .is_some_and(|variants| variants.iter().any(allows_null))
78}
79
80fn make_node_strict(schema: &mut Value, check: Option<KeywordCheck>) -> Result<(), String> {
81    let Some(object) = schema.as_object_mut() else {
82        return Err("boolean schemas are unsupported".into());
83    };
84    for key in UNSUPPORTED_KEYS {
85        if object.get(*key).is_some_and(|value| !value.is_null()) {
86            return Err(format!("{key} schemas are unsupported"));
87        }
88    }
89    if let Some(check) = check {
90        for (key, value) in object.iter() {
91            if check(key, value) {
92                return Err(format!(
93                    "{key}: {} is unsupported",
94                    yapi_types::json::stringify(value)
95                ));
96            }
97        }
98    }
99    if let Some(any_of) = object.get_mut("anyOf") {
100        let Some(variants) = any_of
101            .as_array_mut()
102            .filter(|variants| !variants.is_empty())
103        else {
104            return Err("anyOf must contain at least one schema".into());
105        };
106        for variant in variants {
107            if is_structured(variant) {
108                return Err("object and array unions are unsupported".into());
109            }
110            make_node_strict(variant, check)?;
111        }
112    }
113    if let Some(items) = object.get_mut("items") {
114        if items.is_array() {
115            return Err("tuple schemas are unsupported".into());
116        }
117        make_node_strict(items, check)?;
118    }
119
120    let is_object = object.get("type") == Some(&Value::from("object"));
121    if object.contains_key("properties") && !is_object {
122        return Err("properties require type object".into());
123    }
124    if !is_object {
125        return Ok(());
126    }
127    if object
128        .get("additionalProperties")
129        .is_some_and(|value| value != &Value::Bool(false))
130    {
131        return Err("schema-valued or true additionalProperties is unsupported".into());
132    }
133    let required: Vec<String> = match object.get("required") {
134        None => Vec::new(),
135        Some(Value::Array(keys)) if keys.iter().all(Value::is_string) => keys
136            .iter()
137            .filter_map(|key| key.as_str().map(str::to_owned))
138            .collect(),
139        Some(_) => return Err("object required must be a string array".into()),
140    };
141    let mut properties = match object.get("properties") {
142        None => Map::new(),
143        Some(Value::Object(properties)) => properties.clone(),
144        Some(_) => return Err("object properties must be a schema map".into()),
145    };
146    if required.iter().any(|key| !properties.contains_key(key)) {
147        return Err("required contains an unknown property".into());
148    }
149    let names: Vec<Value> = properties.keys().cloned().map(Value::String).collect();
150    for (key, property) in properties.iter_mut() {
151        make_node_strict(property, check)?;
152        if !required.contains(key) && !allows_null(property) {
153            let original = std::mem::take(property);
154            *property = serde_json::json!({ "anyOf": [original, { "type": "null" }] });
155        }
156    }
157    if object.contains_key("properties") {
158        object.insert("properties".into(), Value::Object(properties));
159    }
160    object.insert("required".into(), Value::Array(names));
161    object.insert("additionalProperties".into(), Value::Bool(false));
162    Ok(())
163}
164
165/// Whether to send `tool` with strict JSON-schema sampling: it asks for it, the
166/// provider supports it and the schema can be made strict. `Err` when the tool
167/// requires strict sampling that cannot be had.
168pub fn strict_sampling(
169    tool: &ToolDeclaration,
170    supports_strict: bool,
171    check: Option<KeywordCheck>,
172) -> Result<bool, String> {
173    let Some(config) = tool
174        .constrained_sampling
175        .as_ref()
176        .and_then(Value::as_object)
177    else {
178        return Ok(false);
179    };
180    if config.get("type").and_then(Value::as_str) != Some("json_schema") {
181        return Ok(false);
182    }
183    let required = config.get("strict").and_then(Value::as_str) == Some("require");
184    if supports_strict {
185        return match make_strict(&tool.parameters, check) {
186            Ok(_) => Ok(true),
187            Err(_) if !required => Ok(false),
188            Err(reason) => Err(format!(
189                "Tool \"{}\" requires JSON-schema constrained sampling, but {reason}.",
190                tool.name
191            )),
192        };
193    }
194    if required {
195        return Err(format!(
196            "Tool \"{}\" requires JSON-schema constrained sampling, but strict tools are unsupported.",
197            tool.name
198        ));
199    }
200    Ok(false)
201}
202
203/// The parameters to send: strict when `strict`, as declared otherwise.
204pub fn tool_parameters(tool: &ToolDeclaration, strict: bool) -> Value {
205    if strict {
206        make_strict(&tool.parameters, None).unwrap_or_else(|_| tool.parameters.clone())
207    } else {
208        tool.parameters.clone()
209    }
210}
211
212/// How to send a tool as an OpenAI grammar-constrained custom tool.
213#[derive(Debug, PartialEq)]
214pub struct Grammar<'a> {
215    /// `lark` or `regex`.
216    pub syntax: &'static str,
217    /// The grammar, in `syntax`.
218    pub definition: &'a str,
219    /// The tool's one required string argument, which holds the raw input.
220    pub property: &'a str,
221}
222
223/// The grammar to send `tool` with: it asks for one and the provider supports
224/// OpenAI grammar tools. Lark is preferred over regex. `Err` when the tool asks
225/// for a grammar it cannot use.
226pub fn grammar_sampling(
227    tool: &ToolDeclaration,
228    supports_grammar: bool,
229) -> Result<Option<Grammar<'_>>, String> {
230    let Some(config) = tool
231        .constrained_sampling
232        .as_ref()
233        .filter(|config| config["type"] == "grammar")
234    else {
235        return Ok(None);
236    };
237    if !supports_grammar {
238        return Ok(None);
239    }
240    let unusable = |reason: &str| {
241        format!(
242            "Tool \"{}\" cannot use grammar constrained sampling: {reason}.",
243            tool.name
244        )
245    };
246    let variant = |name: &str| {
247        config["variants"][name]
248            .as_str()
249            .filter(|definition| !definition.trim().is_empty())
250    };
251    let (syntax, definition) = match (variant("openai_lark"), variant("openai_regex")) {
252        (Some(lark), _) => ("lark", lark),
253        (None, Some(regex)) => ("regex", regex),
254        (None, None) => return Err(unusable("no supported grammar variant was provided")),
255    };
256    let property = grammar_property(&tool.parameters).map_err(|reason| unusable(&reason))?;
257    Ok(Some(Grammar {
258        syntax,
259        definition,
260        property,
261    }))
262}
263
264fn grammar_property(schema: &Value) -> Result<&str, String> {
265    if schema["type"] != "object" {
266        return Err("grammar constrained sampling requires an object parameter schema".into());
267    }
268    let Some([Value::String(property)]) = schema["required"].as_array().map(Vec::as_slice) else {
269        return Err(
270            "grammar constrained sampling requires exactly one required string property".into(),
271        );
272    };
273    let entry = &schema["properties"][property];
274    if !is_truthy(entry) {
275        return Err(format!(
276            "grammar constrained sampling requires a properties entry for {property}"
277        ));
278    }
279    if entry["type"] != "string" {
280        return Err(format!(
281            "grammar constrained sampling property {property} must have type string"
282        ));
283    }
284    Ok(property)
285}
286
287/// The tools a transcript declares that are sent with a grammar, by name, with
288/// the argument that holds each one's raw input. `messages` is the
289/// transcript as the provider receives it, after
290/// [`resolve_transcript`](crate::transcript::resolve_transcript): a model that
291/// folds later system messages into the first one sees only the current tools.
292pub fn grammar_properties(
293    messages: &[Message],
294    supports_grammar: bool,
295) -> Result<HashMap<String, String>, String> {
296    let mut properties = HashMap::new();
297    for tool in declared_tools(messages) {
298        if let Some(grammar) = grammar_sampling(&tool, supports_grammar)? {
299            properties.insert(tool.name.clone(), grammar.property.to_owned());
300        }
301    }
302    Ok(properties)
303}
304
305/// The raw input of a grammar tool call, from the argument that holds it.
306pub fn grammar_input<'a>(
307    name: &str,
308    arguments: &'a Map<String, Value>,
309    property: &str,
310) -> Result<&'a str, String> {
311    arguments
312        .get(property)
313        .and_then(Value::as_str)
314        .ok_or_else(|| {
315            format!("Grammar tool call \"{name}\" requires argument \"{property}\" to be a string.")
316        })
317}
318
319/// A grammar tool call's raw input as it streams, re-encoded as the JSON text
320/// of its arguments object.
321#[derive(Debug)]
322pub struct GrammarInput {
323    /// The argument that holds the raw input.
324    pub property: String,
325    input: String,
326    started: bool,
327    closed: bool,
328}
329
330impl GrammarInput {
331    fn new(property: String) -> GrammarInput {
332        GrammarInput {
333            property,
334            input: String::new(),
335            started: false,
336            closed: false,
337        }
338    }
339
340    /// The input of a custom tool call to `name`, held in the argument that
341    /// `properties` (from [`grammar_properties`]) gives for it. A call to a tool
342    /// sent without a grammar still keeps its input, as `input`.
343    pub fn for_call(properties: &HashMap<String, String>, name: &str) -> GrammarInput {
344        let property = properties.get(name).map_or("input", String::as_str);
345        GrammarInput::new(property.to_owned())
346    }
347
348    /// The raw input `arguments` hold.
349    pub fn input<'a>(&self, arguments: &'a Map<String, Value>) -> &'a str {
350        arguments
351            .get(&self.property)
352            .and_then(Value::as_str)
353            .unwrap_or_default()
354    }
355
356    /// Sets `arguments` to hold `input`. Returns the JSON text that takes the
357    /// arguments streamed so far to it, closing the object when `close`, or
358    /// `None` when there is nothing to stream. `Err`, leaving `arguments` as
359    /// they are, when `input` does not extend the input streamed so far, or
360    /// changes once closed.
361    pub fn set(
362        &mut self,
363        arguments: &mut Map<String, Value>,
364        input: String,
365        close: bool,
366    ) -> Result<Option<String>, String> {
367        let delta = self.append(&input, close)?;
368        *arguments = Map::from_iter([(self.property.clone(), Value::String(input))]);
369        Ok(delta)
370    }
371
372    fn append(&mut self, input: &str, close: bool) -> Result<Option<String>, String> {
373        if self.closed {
374            if close && input == self.input {
375                return Ok(None);
376            }
377            return Err(format!(
378                "grammar tool input for property \"{}\" changed after it was closed",
379                self.property
380            ));
381        }
382        let Some(added) = input.strip_prefix(self.input.as_str()) else {
383            return Err(format!(
384                "grammar tool input for property \"{}\" changed non-monotonically",
385                self.property
386            ));
387        };
388        if !close && added.is_empty() {
389            return Ok(None);
390        }
391        let mut delta = String::new();
392        if !self.started {
393            delta.push('{');
394            delta.push_str(&yapi_types::json::stringify(&Value::from(
395                self.property.as_str(),
396            )));
397            delta.push_str(":\"");
398            self.started = true;
399        }
400        let quoted = yapi_types::json::stringify(&Value::from(added));
401        delta.push_str(&quoted[1..quoted.len() - 1]);
402        if close {
403            delta.push_str("\"}");
404            self.closed = true;
405        }
406        self.input = input.to_owned();
407        Ok(Some(delta))
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use serde_json::json;
415
416    #[test]
417    fn makes_optional_properties_nullable() {
418        let schema = json!({"type":"object","required":["path"],"properties":{
419            "path":{"type":"string"},"limit":{"type":"number","description":"n"}}});
420        let strict = make_strict(&schema, None).unwrap();
421        assert_eq!(
422            yapi_types::json::to_string(&strict).unwrap(),
423            r#"{"type":"object","required":["path","limit"],"properties":{"path":{"type":"string"},"limit":{"anyOf":[{"type":"number","description":"n"},{"type":"null"}]}},"additionalProperties":false}"#
424        );
425    }
426
427    #[test]
428    fn rejects_unsupported_constructs() {
429        assert!(
430            make_strict(
431                &json!({"type":"object","properties":{"a":{"oneOf":[]}}}),
432                None
433            )
434            .is_err()
435        );
436        assert!(make_strict(&json!({"type":"object","additionalProperties":true}), None).is_err());
437        assert!(make_strict(&json!({"type":"string"}), None).is_err());
438    }
439
440    #[test]
441    fn resolves_grammar_tools() {
442        let tool = |sampling: Value, parameters: Value| ToolDeclaration {
443            name: "codemode".into(),
444            description: String::new(),
445            parameters,
446            constrained_sampling: Some(sampling),
447        };
448        let code =
449            json!({"type":"object","required":["code"],"properties":{"code":{"type":"string"}}});
450        let both = tool(
451            json!({"type":"grammar","variants":{"openai_regex":"a+","openai_lark":"start: \"a\""}}),
452            code.clone(),
453        );
454        assert_eq!(
455            grammar_sampling(&both, true).unwrap(),
456            Some(Grammar {
457                syntax: "lark",
458                definition: "start: \"a\"",
459                property: "code"
460            })
461        );
462        assert_eq!(grammar_sampling(&both, false).unwrap(), None);
463        let regex = tool(
464            json!({"type":"grammar","variants":{"openai_lark":" ","openai_regex":"a+"}}),
465            code.clone(),
466        );
467        assert_eq!(
468            grammar_sampling(&regex, true).unwrap().unwrap().syntax,
469            "regex"
470        );
471        let empty = tool(json!({"type":"grammar","variants":{}}), code);
472        assert_eq!(
473            grammar_sampling(&empty, true).unwrap_err(),
474            "Tool \"codemode\" cannot use grammar constrained sampling: no supported grammar variant was provided."
475        );
476        let two = tool(
477            json!({"type":"grammar","variants":{"openai_regex":"a+"}}),
478            json!({"type":"object","required":["a","b"],"properties":{"a":{"type":"string"},"b":{"type":"string"}}}),
479        );
480        assert_eq!(
481            grammar_sampling(&two, true).unwrap_err(),
482            "Tool \"codemode\" cannot use grammar constrained sampling: grammar constrained sampling requires exactly one required string property."
483        );
484        let unusable = |parameters: Value| {
485            grammar_sampling(
486                &tool(
487                    json!({"type":"grammar","variants":{"openai_regex":"a+"}}),
488                    parameters,
489                ),
490                true,
491            )
492            .unwrap_err()
493        };
494        let prefix = "Tool \"codemode\" cannot use grammar constrained sampling: grammar constrained sampling";
495        assert_eq!(
496            unusable(json!({"type":"string"})),
497            format!("{prefix} requires an object parameter schema.")
498        );
499        assert_eq!(
500            unusable(json!({"type":"object","required":["code"],"properties":{}})),
501            format!("{prefix} requires a properties entry for code.")
502        );
503        assert_eq!(
504            unusable(
505                json!({"type":"object","required":["code"],"properties":{"code":{"type":"number"}}})
506            ),
507            format!("{prefix} property code must have type string.")
508        );
509    }
510
511    #[test]
512    fn grammar_input_as_json() {
513        let mut input = GrammarInput::new("code".into());
514        assert_eq!(input.append("", false).unwrap(), None);
515        assert_eq!(
516            input.append("say(\"hi\")", false).unwrap().as_deref(),
517            Some(r#"{"code":"say(\"hi\")"#)
518        );
519        assert_eq!(
520            input.append("say(\"hi\")\n", true).unwrap().as_deref(),
521            Some(r#"\n"}"#)
522        );
523        assert_eq!(input.append("say(\"hi\")\n", true).unwrap(), None);
524        assert!(input.append("other", true).is_err());
525        let mut input = GrammarInput::new("code".into());
526        input.append("ab", false).unwrap();
527        assert!(input.append("b", false).is_err());
528        let mut input = GrammarInput::new("code".into());
529        assert_eq!(
530            input.append("", true).unwrap().as_deref(),
531            Some(r#"{"code":""}"#)
532        );
533    }
534}