1use oino_ai::{JsonObject, JsonValue, ToolCall};
15use regex::Regex;
16use serde_json::Number;
17
18pub fn validate_tool_arguments(schema: &JsonValue, call: &ToolCall) -> Result<JsonObject, String> {
26 if let Some(error) = &call.arguments_error {
27 return Err(format!(
28 "Invalid arguments for tool \"{}\": {error}",
29 call.name
30 ));
31 }
32 let mut args = JsonValue::Object(call.arguments.clone());
33 normalize_optional_nulls(&mut args, schema, schema);
34 let args = coerce(args, schema, schema);
35 let errors = Validator::new(schema).errors(&args);
36 if errors.is_empty() {
37 return Ok(match args {
38 JsonValue::Object(map) => map,
39 _ => call.arguments.clone(),
40 });
41 }
42 let list = errors
43 .iter()
44 .map(|error| format!(" - {}: {}", error.path, error.message))
45 .collect::<Vec<_>>()
46 .join("\n");
47 let received = serde_json::to_string_pretty(&JsonValue::Object(call.arguments.clone()))
48 .unwrap_or_else(|_| "{}".to_owned());
49 Err(format!(
50 "Validation failed for tool \"{}\":\n{list}\n\nReceived arguments:\n{received}",
51 call.name
52 ))
53}
54
55#[derive(Clone, Debug, PartialEq, Eq)]
57pub struct ValidationError {
58 pub path: String,
60 pub message: String,
62}
63
64#[must_use]
66pub fn check(schema: &JsonValue, value: &JsonValue) -> bool {
67 Validator::new(schema).errors(value).is_empty()
68}
69
70fn resolve<'a>(root: &'a JsonValue, schema: &'a JsonValue) -> &'a JsonValue {
71 let mut current = schema;
72 for _ in 0..32 {
73 let Some(reference) = current.get("$ref").and_then(JsonValue::as_str) else {
74 return current;
75 };
76 let Some(pointer) = reference.strip_prefix('#') else {
77 return current;
78 };
79 match root.pointer(pointer) {
80 Some(target) => current = target,
81 None => return current,
82 }
83 }
84 current
85}
86
87fn schema_types(schema: &JsonValue) -> Vec<&str> {
88 match schema.get("type") {
89 Some(JsonValue::String(kind)) => vec![kind.as_str()],
90 Some(JsonValue::Array(kinds)) => kinds.iter().filter_map(JsonValue::as_str).collect(),
91 _ => Vec::new(),
92 }
93}
94
95fn matches_type(value: &JsonValue, kind: &str) -> bool {
96 match kind {
97 "number" => value.is_number(),
98 "integer" => value.as_f64().is_some_and(|n| n.fract() == 0.0),
99 "boolean" => value.is_boolean(),
100 "string" => value.is_string(),
101 "null" => value.is_null(),
102 "array" => value.is_array(),
103 "object" => value.is_object(),
104 _ => false,
105 }
106}
107
108fn normalize_optional_nulls(value: &mut JsonValue, schema: &JsonValue, root: &JsonValue) {
109 match value {
110 JsonValue::Array(items) => match schema.get("items") {
111 Some(JsonValue::Array(item_schemas)) => {
112 for (item, item_schema) in items.iter_mut().zip(item_schemas) {
113 normalize_optional_nulls(item, item_schema, root);
114 }
115 }
116 Some(item_schema) => {
117 for item in items {
118 normalize_optional_nulls(item, item_schema, root);
119 }
120 }
121 None => {}
122 },
123 JsonValue::Object(object) => {
124 let Some(JsonValue::Object(properties)) = schema.get("properties") else {
125 return;
126 };
127 let required: Vec<&str> = schema
128 .get("required")
129 .and_then(JsonValue::as_array)
130 .map(|r| r.iter().filter_map(JsonValue::as_str).collect())
131 .unwrap_or_default();
132 for (key, property_schema) in properties {
133 let Some(current) = object.get_mut(key) else {
134 continue;
135 };
136 let removable = current.is_null()
137 && !required.contains(&key.as_str())
138 && property_schema.get("$ref").is_none()
139 && !check_in(root, property_schema, &JsonValue::Null);
140 if removable {
141 object.shift_remove(key);
142 } else {
143 normalize_optional_nulls(current, property_schema, root);
144 }
145 }
146 }
147 _ => {}
148 }
149}
150
151fn check_in(root: &JsonValue, schema: &JsonValue, value: &JsonValue) -> bool {
152 let validator = Validator { root };
153 let mut errors = Vec::new();
154 validator.collect(schema, value, &mut Vec::new(), &mut errors);
155 errors.is_empty()
156}
157
158fn coerce(value: JsonValue, schema: &JsonValue, root: &JsonValue) -> JsonValue {
160 let mut next = value;
161 if let Some(JsonValue::Array(all)) = schema.get("allOf") {
162 for nested in all {
163 next = coerce(next, nested, root);
164 }
165 }
166 for union in ["anyOf", "oneOf"] {
167 if let Some(JsonValue::Array(members)) = schema.get(union) {
168 next = coerce_union(next, members, root);
169 }
170 }
171 let types = schema_types(schema);
172 let matches_member = types.len() > 1 && types.iter().any(|kind| matches_type(&next, kind));
173 if !types.is_empty() && !matches_member {
174 for kind in &types {
175 if let Some(candidate) = coerce_primitive(&next, kind) {
176 next = candidate;
177 break;
178 }
179 }
180 }
181 if types.contains(&"object")
182 && let JsonValue::Object(object) = &mut next
183 {
184 coerce_object(object, schema, root);
185 }
186 if types.contains(&"array")
187 && let JsonValue::Array(items) = &mut next
188 {
189 coerce_array(items, schema, root);
190 }
191 next
192}
193
194fn coerce_union(value: JsonValue, members: &[JsonValue], root: &JsonValue) -> JsonValue {
195 if members.iter().any(|member| check_in(root, member, &value)) {
196 return value;
197 }
198 for member in members {
199 let candidate = coerce(value.clone(), member, root);
200 if check_in(root, member, &candidate) {
201 return candidate;
202 }
203 }
204 value
205}
206
207fn coerce_object(object: &mut JsonObject, schema: &JsonValue, root: &JsonValue) {
208 let properties = schema.get("properties").and_then(JsonValue::as_object);
209 if let Some(properties) = properties {
210 for (key, property_schema) in properties {
211 if let Some(slot) = object.get_mut(key) {
212 *slot = coerce(slot.take(), property_schema, root);
213 }
214 }
215 }
216 if let Some(additional @ JsonValue::Object(_)) = schema.get("additionalProperties") {
217 for (key, slot) in object.iter_mut() {
218 if properties.is_some_and(|p| p.contains_key(key)) {
219 continue;
220 }
221 *slot = coerce(slot.take(), additional, root);
222 }
223 }
224}
225
226fn coerce_array(items: &mut [JsonValue], schema: &JsonValue, root: &JsonValue) {
227 match schema.get("items") {
228 Some(JsonValue::Array(item_schemas)) => {
229 for (item, item_schema) in items.iter_mut().zip(item_schemas) {
230 *item = coerce(item.take(), item_schema, root);
231 }
232 }
233 Some(item_schema @ JsonValue::Object(_)) => {
234 for item in items.iter_mut() {
235 *item = coerce(item.take(), item_schema, root);
236 }
237 }
238 _ => {}
239 }
240}
241
242fn coerce_primitive(value: &JsonValue, kind: &str) -> Option<JsonValue> {
244 match (kind, value) {
245 ("number" | "integer", JsonValue::Null) => Some(JsonValue::from(0)),
246 ("number", JsonValue::String(text)) if !text.trim().is_empty() => js_to_number(text)
247 .filter(|n| n.is_finite())
248 .map(number_value),
249 ("integer", JsonValue::String(text)) if !text.trim().is_empty() => js_to_number(text)
250 .filter(|n| n.is_finite() && n.fract() == 0.0)
251 .map(number_value),
252 ("number" | "integer", JsonValue::Bool(flag)) => Some(JsonValue::from(u8::from(*flag))),
253 ("boolean", JsonValue::Null) => Some(JsonValue::Bool(false)),
254 ("boolean", JsonValue::String(text)) => match text.as_str() {
255 "true" => Some(JsonValue::Bool(true)),
256 "false" => Some(JsonValue::Bool(false)),
257 _ => None,
258 },
259 ("boolean", JsonValue::Number(n)) => match n.as_f64() {
260 Some(1.0) => Some(JsonValue::Bool(true)),
261 Some(0.0) => Some(JsonValue::Bool(false)),
262 _ => None,
263 },
264 ("string", JsonValue::Null) => Some(JsonValue::String(String::new())),
265 ("string", JsonValue::Number(n)) => Some(JsonValue::String(js_number_text(n))),
266 ("string", JsonValue::Bool(flag)) => Some(JsonValue::String(flag.to_string())),
267 ("null", JsonValue::String(text)) if text.is_empty() => Some(JsonValue::Null),
268 ("null", JsonValue::Bool(false)) => Some(JsonValue::Null),
269 ("null", JsonValue::Number(n)) if n.as_f64() == Some(0.0) => Some(JsonValue::Null),
270 _ => None,
271 }
272}
273
274#[allow(
276 clippy::cast_possible_truncation,
277 reason = "value is integral and within ±2^53, so the cast is exact"
278)]
279fn number_value(value: f64) -> JsonValue {
280 if value.fract() == 0.0 && value.abs() <= 9_007_199_254_740_991.0 {
281 JsonValue::from(value as i64)
282 } else {
283 Number::from_f64(value).map_or(JsonValue::Null, JsonValue::Number)
284 }
285}
286
287fn js_to_number(text: &str) -> Option<f64> {
289 let trimmed = text.trim_matches(|c: char| c.is_whitespace() || c == '\u{feff}');
290 if trimmed.is_empty() {
291 return Some(0.0);
292 }
293 for (prefix, radix) in [
294 ("0x", 16),
295 ("0X", 16),
296 ("0o", 8),
297 ("0O", 8),
298 ("0b", 2),
299 ("0B", 2),
300 ] {
301 if let Some(digits) = trimmed.strip_prefix(prefix) {
302 return u64::from_str_radix(digits, radix).ok().map(|n| {
303 #[allow(clippy::cast_precision_loss, reason = "JavaScript numbers are f64")]
304 let n = n as f64;
305 n
306 });
307 }
308 }
309 let unsigned = trimmed.trim_start_matches(['+', '-']);
310 if unsigned == "Infinity" && trimmed.len() - unsigned.len() <= 1 {
311 return Some(if trimmed.starts_with('-') {
312 f64::NEG_INFINITY
313 } else {
314 f64::INFINITY
315 });
316 }
317 let valid = trimmed.len() - unsigned.len() <= 1
318 && !unsigned.is_empty()
319 && unsigned
320 .chars()
321 .all(|c| c.is_ascii_digit() || matches!(c, '.' | 'e' | 'E' | '+' | '-'))
322 && unsigned
323 .chars()
324 .next()
325 .is_some_and(|c| c.is_ascii_digit() || c == '.');
326 if !valid {
327 return None;
328 }
329 trimmed.parse::<f64>().ok()
330}
331
332fn js_number_text(number: &Number) -> String {
334 if number.is_i64() || number.is_u64() {
335 return number.to_string();
336 }
337 let Some(value) = number.as_f64() else {
338 return number.to_string();
339 };
340 js_f64_text(value)
341}
342
343fn js_f64_text(value: f64) -> String {
344 if value.fract() == 0.0 && value.abs() < 1e21 {
345 return format!("{value:.0}");
346 }
347 let magnitude = value.abs();
348 if (1e-7..1e21).contains(&magnitude) {
349 return format!("{value}");
350 }
351 let formatted = format!("{value:e}");
352 match formatted.split_once('e') {
353 Some((mantissa, exponent)) if !exponent.starts_with('-') => {
354 format!("{mantissa}e+{exponent}")
355 }
356 _ => formatted,
357 }
358}
359
360type NumberCheck = (&'static str, &'static str, fn(f64, f64) -> bool);
362
363#[allow(clippy::cast_precision_loss, reason = "sizes stay far below 2^53")]
365fn count_f64(count: usize) -> f64 {
366 count as f64
367}
368
369struct Validator<'a> {
370 root: &'a JsonValue,
371}
372
373impl<'a> Validator<'a> {
374 fn new(root: &'a JsonValue) -> Self {
375 Self { root }
376 }
377
378 fn errors(&self, value: &JsonValue) -> Vec<ValidationError> {
379 let mut errors = Vec::new();
380 self.collect(self.root, value, &mut Vec::new(), &mut errors);
381 errors
382 }
383
384 fn push(errors: &mut Vec<ValidationError>, path: &[String], message: String) {
385 let path = if path.is_empty() {
386 "root".to_owned()
387 } else {
388 path.join(".")
389 };
390 errors.push(ValidationError { path, message });
391 }
392
393 fn collect(
394 &self,
395 schema: &JsonValue,
396 value: &JsonValue,
397 path: &mut Vec<String>,
398 errors: &mut Vec<ValidationError>,
399 ) {
400 let schema = resolve(self.root, schema);
401 match schema {
402 JsonValue::Bool(false) => {
403 Self::push(errors, path, "schema is false".to_owned());
404 return;
405 }
406 JsonValue::Object(_) => {}
407 _ => return,
408 }
409 let types = schema_types(schema);
410 if !types.is_empty() && !types.iter().any(|kind| matches_type(value, kind)) {
411 Self::push(errors, path, format!("must be {}", types.join(" or ")));
412 return;
413 }
414 if let Some(expected) = schema.get("const")
415 && !json_equal(expected, value)
416 {
417 Self::push(errors, path, "must be equal to constant".to_owned());
418 }
419 if let Some(JsonValue::Array(allowed)) = schema.get("enum")
420 && !allowed.iter().any(|candidate| json_equal(candidate, value))
421 {
422 Self::push(
423 errors,
424 path,
425 "must be equal to one of the allowed values".to_owned(),
426 );
427 }
428 match value {
429 JsonValue::String(text) => Self::string_keywords(schema, text, path, errors),
430 JsonValue::Number(number) => {
431 Self::number_keywords(schema, number.as_f64().unwrap_or(0.0), path, errors);
432 }
433 JsonValue::Array(items) => self.array_keywords(schema, items, path, errors),
434 JsonValue::Object(object) => self.object_keywords(schema, object, path, errors),
435 _ => {}
436 }
437 self.combinators(schema, value, path, errors);
438 }
439
440 fn string_keywords(
441 schema: &JsonValue,
442 text: &str,
443 path: &[String],
444 errors: &mut Vec<ValidationError>,
445 ) {
446 let length = text.encode_utf16().count();
447 if let Some(min) = schema.get("minLength").and_then(JsonValue::as_f64)
448 && count_f64(length) < min
449 {
450 Self::push(
451 errors,
452 path,
453 format!("must not have fewer than {} characters", js_f64_text(min)),
454 );
455 }
456 if let Some(max) = schema.get("maxLength").and_then(JsonValue::as_f64)
457 && count_f64(length) > max
458 {
459 Self::push(
460 errors,
461 path,
462 format!("must not have more than {} characters", js_f64_text(max)),
463 );
464 }
465 if let Some(pattern) = schema.get("pattern").and_then(JsonValue::as_str)
466 && Regex::new(pattern).is_ok_and(|re| !re.is_match(text))
467 {
468 Self::push(errors, path, format!("must match pattern \"{pattern}\""));
469 }
470 }
471
472 fn number_keywords(
473 schema: &JsonValue,
474 number: f64,
475 path: &[String],
476 errors: &mut Vec<ValidationError>,
477 ) {
478 let bound = |key: &str| schema.get(key).and_then(JsonValue::as_f64);
479 let checks: [NumberCheck; 4] = [
480 ("minimum", ">=", |n, b| n >= b),
481 ("maximum", "<=", |n, b| n <= b),
482 ("exclusiveMinimum", ">", |n, b| n > b),
483 ("exclusiveMaximum", "<", |n, b| n < b),
484 ];
485 for (key, operator, ok) in checks {
486 if let Some(limit) = bound(key)
487 && !ok(number, limit)
488 {
489 Self::push(
490 errors,
491 path,
492 format!("must be {operator} {}", js_f64_text(limit)),
493 );
494 }
495 }
496 if let Some(step) = bound("multipleOf")
497 && step != 0.0
498 && (number / step).fract() != 0.0
499 {
500 Self::push(
501 errors,
502 path,
503 format!("must be multiple of {}", js_f64_text(step)),
504 );
505 }
506 }
507
508 fn array_keywords(
509 &self,
510 schema: &JsonValue,
511 items: &[JsonValue],
512 path: &mut Vec<String>,
513 errors: &mut Vec<ValidationError>,
514 ) {
515 let count = count_f64(items.len());
516 if let Some(min) = schema.get("minItems").and_then(JsonValue::as_f64)
517 && count < min
518 {
519 Self::push(
520 errors,
521 path,
522 format!("must not have fewer than {} items", js_f64_text(min)),
523 );
524 }
525 if let Some(max) = schema.get("maxItems").and_then(JsonValue::as_f64)
526 && count > max
527 {
528 Self::push(
529 errors,
530 path,
531 format!("must not have more than {} items", js_f64_text(max)),
532 );
533 }
534 if schema.get("uniqueItems") == Some(&JsonValue::Bool(true)) {
535 let duplicate = items
536 .iter()
537 .enumerate()
538 .any(|(i, a)| items[i + 1..].iter().any(|b| json_equal(a, b)));
539 if duplicate {
540 Self::push(errors, path, "must not have duplicate items".to_owned());
541 }
542 }
543 if let Some(contains) = schema.get("contains")
544 && !items.iter().any(|item| check_in(self.root, contains, item))
545 {
546 Self::push(
547 errors,
548 path,
549 "must contain at least 1 valid item".to_owned(),
550 );
551 }
552 let prefix = match (schema.get("prefixItems"), schema.get("items")) {
553 (Some(JsonValue::Array(prefix)), _) | (None, Some(JsonValue::Array(prefix))) => {
554 Some(prefix.as_slice())
555 }
556 _ => None,
557 };
558 let rest = match schema.get("items") {
559 Some(rest @ (JsonValue::Object(_) | JsonValue::Bool(_))) => Some(rest),
560 _ => None,
561 };
562 for (index, item) in items.iter().enumerate() {
563 let item_schema =
564 prefix
565 .and_then(|p| p.get(index))
566 .or(if prefix.is_some() { None } else { rest });
567 if let Some(item_schema) = item_schema {
568 path.push(index.to_string());
569 self.collect(item_schema, item, path, errors);
570 path.pop();
571 }
572 }
573 }
574
575 fn object_keywords(
576 &self,
577 schema: &JsonValue,
578 object: &JsonObject,
579 path: &mut Vec<String>,
580 errors: &mut Vec<ValidationError>,
581 ) {
582 if let Some(JsonValue::Array(required)) = schema.get("required") {
583 let missing: Vec<&str> = required
584 .iter()
585 .filter_map(JsonValue::as_str)
586 .filter(|key| !object.contains_key(*key))
587 .collect();
588 if let Some(first) = missing.first() {
589 path.push((*first).to_owned());
590 Self::push(
591 errors,
592 path,
593 format!("must have required properties {}", missing.join(", ")),
594 );
595 path.pop();
596 }
597 }
598 let properties = schema.get("properties").and_then(JsonValue::as_object);
599 if let Some(properties) = properties {
600 for (key, property_schema) in properties {
601 if let Some(property) = object.get(key) {
602 path.push(key.clone());
603 self.collect(property_schema, property, path, errors);
604 path.pop();
605 }
606 }
607 }
608 if let Some(additional) = schema.get("additionalProperties") {
609 let extras: Vec<(&String, &JsonValue)> = object
610 .iter()
611 .filter(|(key, _)| !properties.is_some_and(|p| p.contains_key(*key)))
612 .collect();
613 let mut failed = false;
614 for (key, extra) in extras {
615 if additional == &JsonValue::Bool(true) {
616 continue;
617 }
618 let before = errors.len();
619 path.push(key.clone());
620 self.collect(additional, extra, path, errors);
621 path.pop();
622 failed |= errors.len() > before;
623 }
624 if failed {
625 Self::push(
626 errors,
627 path,
628 "must not have additional properties".to_owned(),
629 );
630 }
631 }
632 let count = count_f64(object.len());
633 if let Some(min) = schema.get("minProperties").and_then(JsonValue::as_f64)
634 && count < min
635 {
636 Self::push(
637 errors,
638 path,
639 format!("must not have fewer than {} properties", js_f64_text(min)),
640 );
641 }
642 if let Some(max) = schema.get("maxProperties").and_then(JsonValue::as_f64)
643 && count > max
644 {
645 Self::push(
646 errors,
647 path,
648 format!("must not have more than {} properties", js_f64_text(max)),
649 );
650 }
651 }
652
653 fn combinators(
654 &self,
655 schema: &JsonValue,
656 value: &JsonValue,
657 path: &mut Vec<String>,
658 errors: &mut Vec<ValidationError>,
659 ) {
660 if let Some(JsonValue::Array(all)) = schema.get("allOf") {
661 for member in all {
662 self.collect(member, value, path, errors);
663 }
664 }
665 if let Some(JsonValue::Array(any)) = schema.get("anyOf") {
666 let mut member_errors = Vec::new();
667 let mut matched = false;
668 for member in any {
669 let before = member_errors.len();
670 self.collect(member, value, path, &mut member_errors);
671 if member_errors.len() == before {
672 matched = true;
673 break;
674 }
675 }
676 if !matched {
677 errors.extend(member_errors);
678 Self::push(errors, path, "must match a schema in anyOf".to_owned());
679 }
680 }
681 if let Some(JsonValue::Array(one)) = schema.get("oneOf") {
682 let mut member_errors = Vec::new();
683 let mut matches = 0;
684 for member in one {
685 let before = member_errors.len();
686 self.collect(member, value, path, &mut member_errors);
687 if member_errors.len() == before {
688 matches += 1;
689 }
690 }
691 if matches != 1 {
692 if matches == 0 {
693 errors.extend(member_errors);
694 }
695 Self::push(
696 errors,
697 path,
698 "must match exactly one schema in oneOf".to_owned(),
699 );
700 }
701 }
702 if let Some(not) = schema.get("not")
703 && check_in(self.root, not, value)
704 {
705 Self::push(errors, path, "must not be valid".to_owned());
706 }
707 }
708}
709
710fn json_equal(left: &JsonValue, right: &JsonValue) -> bool {
712 match (left, right) {
713 (JsonValue::Number(a), JsonValue::Number(b)) => a.as_f64() == b.as_f64(),
714 (JsonValue::Array(a), JsonValue::Array(b)) => {
715 a.len() == b.len() && a.iter().zip(b).all(|(x, y)| json_equal(x, y))
716 }
717 (JsonValue::Object(a), JsonValue::Object(b)) => {
718 a.len() == b.len()
719 && a.iter()
720 .all(|(key, x)| b.get(key).is_some_and(|y| json_equal(x, y)))
721 }
722 _ => left == right,
723 }
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729
730 #[test]
731 fn js_number_parsing() {
732 assert_eq!(js_to_number(" 3 "), Some(3.0));
733 assert_eq!(js_to_number("0x10"), Some(16.0));
734 assert_eq!(js_to_number("1e3"), Some(1000.0));
735 assert_eq!(js_to_number("abc"), None);
736 assert_eq!(js_to_number("inf"), None);
737 assert_eq!(js_to_number("Infinity"), Some(f64::INFINITY));
738 assert_eq!(js_to_number("-.5"), Some(-0.5));
739 }
740
741 #[test]
742 fn js_number_printing() {
743 assert_eq!(js_f64_text(1.5), "1.5");
744 assert_eq!(js_f64_text(1e21), "1e+21");
745 assert_eq!(js_f64_text(3.0), "3");
746 }
747}