1use 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
13const 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
33pub type KeywordCheck = fn(&str, &Value) -> bool;
35
36pub 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
165pub 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
203pub 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#[derive(Debug, PartialEq)]
214pub struct Grammar<'a> {
215 pub syntax: &'static str,
217 pub definition: &'a str,
219 pub property: &'a str,
221}
222
223pub 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
287pub 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
305pub 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#[derive(Debug)]
322pub struct GrammarInput {
323 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 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 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 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("ed[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(®ex, 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}