返回 CodeWhale
schema_sanitize.rs
根目录 / crates / tui / src / tools / schema_sanitize.rs
1 //! Schema sanitizer for tool `input_schema` before sending to provider APIs.
2 //!
3 //! DeepSeek's `/beta/chat/completions` strict tool mode is harsh. MCP tool
4 //! schemas frequently arrive with Pydantic-style `anyOf:[{type:"string"},
5 //! {type:"null"}]` unions, bare `{type:"object"}` with no `properties`, or
6 //! `required` entries that don't appear in `properties`. These dirty schemas
7 //! cause silent 400s that users can't diagnose.
8 //!
9 //! The default sanitizer runs in-place on every schema returned by
10 //! `ToolRegistry::tools_for_api()` before the registry hands them off.
11 //! Provider-specific helpers below add stricter DeepSeek and OpenAI Responses
12 //! compatibility passes where their request shapes need it.
13
14 use std::collections::HashSet;
15
16 use serde_json::{Map, Value};
17
18 use crate::models::Tool;
19
20 /// Sanitize a JSON Schema in-place for DeepSeek strict-tool compatibility.
21 ///
22 /// Applies a sequence of normalisations chosen to be semantics-preserving:
23 /// - Collapse `{"anyOf":[X, {"type":"null"}]}` → `X ∪ {"nullable": true}`
24 /// - Inject `"properties": {}` on bare-object schemas
25 /// - Prune dangling `required` entries
26 /// - Collapse single-element `oneOf` / `allOf`
27 /// - Walk recursively through all subschemas
28 pub fn sanitize(schema: &mut Value) {
29 collapse_nullable_unions(schema);
30 inject_properties_on_bare_objects(schema);
31 prune_dangling_required(schema);
32 collapse_single_element_unions(schema);
33 // Recurse into all sub-schemas
34 if let Some(obj) = schema.as_object_mut() {
35 for (_, v) in obj.iter_mut() {
36 sanitize(v);
37 }
38 } else if let Some(arr) = schema.as_array_mut() {
39 for v in arr.iter_mut() {
40 sanitize(v);
41 }
42 }
43 }
44
45 /// Prepare a complete active tool set for DeepSeek strict function-calling.
46 ///
47 /// Each tool is evaluated independently: compatible schemas are sanitized and
48 /// marked strict, while incompatible schemas remain unchanged and non-strict.
49 /// Returns `true` only when every tool in the set can use strict mode.
50 pub fn prepare_tools_for_strict_mode(tools: &mut [Tool]) -> bool {
51 let mut all_strict = true;
52 for tool in tools {
53 if strict_schema_supported(&tool.input_schema) {
54 sanitize_for_strict(&mut tool.input_schema);
55 tool.strict = Some(true);
56 } else {
57 tool.strict = None;
58 all_strict = false;
59 }
60 }
61 all_strict
62 }
63
64 /// Sanitize a schema for DeepSeek strict function-calling.
65 ///
66 /// This extends the general sanitizer with the official strict-mode object
67 /// rules: every object must set `additionalProperties: false`, and every
68 /// property must be listed in `required`.
69 pub fn sanitize_for_strict(schema: &mut Value) {
70 sanitize(schema);
71 enforce_strict_subset(schema);
72 }
73
74 /// Sanitize a tool `parameters` schema for xAI chat completions.
75 ///
76 /// xAI validates that the parameters root is an object schema and rejects a
77 /// root-level `anyOf`/`oneOf` union with any non-object branch
78 /// ("tool parameter root must be an object type"). The built-in `apply_patch`
79 /// schema's `oneOf: [{required:["patch"]}, {required:["changes"]}]` trips this
80 /// with a 400 on the first tool-bearing request. The Responses-API pass
81 /// performs exactly the required normalization — merge root composition
82 /// properties, force `type: object`, drop root-only composition keywords —
83 /// so reuse it and surface the dropped constraint as a description note.
84 pub fn sanitize_for_xai_parameters(parameters: &mut Value) -> Option<String> {
85 sanitize_for_responses(parameters)
86 }
87
88 /// Sanitize a schema for OpenAI Responses function tools.
89 ///
90 /// The Responses API requires the top-level `parameters` schema to be an object
91 /// and rejects top-level `oneOf` / `anyOf` / `allOf` / `enum` / `not`. Keep the
92 /// schema permissive rather than changing tool semantics: merge any root
93 /// alternative properties we can see, then remove the root-only composition
94 /// keywords while preserving nested schemas.
95 ///
96 /// Returns a short description note when root composition constraints with
97 /// meaningful `required` groups are dropped.
98 pub fn sanitize_for_responses(schema: &mut Value) -> Option<String> {
99 let constraint_note = schema
100 .as_object()
101 .and_then(root_composition_constraint_note);
102
103 sanitize(schema);
104
105 if !schema.is_object() {
106 *schema = Value::Object(Map::new());
107 }
108
109 let Some(obj) = schema.as_object_mut() else {
110 return constraint_note;
111 };
112
113 merge_root_composition_properties(obj);
114 obj.insert("type".into(), Value::String("object".to_string()));
115 obj.remove("oneOf");
116 obj.remove("anyOf");
117 obj.remove("allOf");
118 obj.remove("enum");
119 obj.remove("not");
120 ensure_properties_object(obj);
121 prune_dangling_required(schema);
122 constraint_note
123 }
124
125 fn strict_schema_supported(schema: &Value) -> bool {
126 let mut normalized = schema.clone();
127 sanitize(&mut normalized);
128 !has_strict_incompatible_composition(&normalized, true)
129 }
130
131 fn has_strict_incompatible_composition(schema: &Value, is_root: bool) -> bool {
132 if let Some(obj) = schema.as_object() {
133 if obj.contains_key("oneOf") || obj.contains_key("allOf") {
134 return true;
135 }
136 if is_root && obj.contains_key("anyOf") {
137 return true;
138 }
139 return obj
140 .values()
141 .any(|value| has_strict_incompatible_composition(value, false));
142 }
143 schema.as_array().is_some_and(|arr| {
144 arr.iter()
145 .any(|value| has_strict_incompatible_composition(value, false))
146 })
147 }
148
149 /// Collapse `{"anyOf":[X, {"type":"null"}]}` → `X ∪ {"nullable": true}`.
150 ///
151 /// Same treatment for `oneOf`. Only collapses when exactly one non-null
152 /// member and exactly one null-type member are present.
153 fn collapse_nullable_unions(schema: &mut Value) {
154 let Some(obj) = schema.as_object_mut() else {
155 return;
156 };
157 for key in ["anyOf", "oneOf"] {
158 let members: Vec<Value> = match obj.get(key).and_then(|v| v.as_array()) {
159 Some(arr) => arr.clone(),
160 None => continue,
161 };
162 let (nulls, nons): (Vec<_>, Vec<_>) = members.into_iter().partition(is_null_type);
163 if nulls.len() == 1 && nons.len() == 1 {
164 // `nullable` is only meaningful when it annotates a concrete
165 // schema type. Collapsing `$ref | null` or another untyped branch
166 // would manufacture an annotation-only schema and lose the
167 // terminating union needed by MFJS reference validation.
168 let has_concrete_non_null_type = nons[0]
169 .as_object()
170 .and_then(|branch| branch.get("type"))
171 .and_then(Value::as_str)
172 .is_some_and(|schema_type| schema_type != "null");
173 if !has_concrete_non_null_type {
174 continue;
175 }
176 obj.remove(key);
177 if let Value::Object(non_obj) = nons.into_iter().next().unwrap() {
178 for (k, v) in non_obj {
179 if k != "type" || v != "null" {
180 obj.insert(k, v);
181 }
182 }
183 }
184 obj.insert("nullable".into(), Value::Bool(true));
185 }
186 }
187 }
188
189 fn is_null_type(v: &Value) -> bool {
190 v.as_object()
191 .and_then(|o| o.get("type"))
192 .and_then(|t| t.as_str())
193 == Some("null")
194 }
195
196 /// Bare `{"type": "object"}` (no `properties`, no `additionalProperties`)
197 /// → inject `"properties": {}` so DeepSeek's strict validator doesn't 400.
198 fn inject_properties_on_bare_objects(schema: &mut Value) {
199 let Some(obj) = schema.as_object_mut() else {
200 return;
201 };
202 if obj.get("type").and_then(|t| t.as_str()) != Some("object") {
203 return;
204 }
205 if obj.contains_key("properties") || obj.contains_key("additionalProperties") {
206 return;
207 }
208 obj.insert("properties".into(), Value::Object(Map::new()));
209 }
210
211 /// Remove entries from `required` that aren't keys in `properties`.
212 fn prune_dangling_required(schema: &mut Value) {
213 let Some(obj) = schema.as_object_mut() else {
214 return;
215 };
216 // Collect known property names first (immutable borrow), then prune.
217 let known_keys: Vec<String> = obj
218 .get("properties")
219 .and_then(|v| v.as_object())
220 .map(|props| props.keys().cloned().collect())
221 .unwrap_or_default();
222 let Some(required) = obj.get_mut("required").and_then(|v| v.as_array_mut()) else {
223 return;
224 };
225 required.retain(|entry| {
226 entry
227 .as_str()
228 .is_some_and(|k| known_keys.iter().any(|known| known == k))
229 });
230 if required.is_empty() {
231 obj.remove("required");
232 }
233 }
234
235 /// Collapse `{"oneOf": [X]}` → X, same for `allOf`.
236 ///
237 /// Single-element unions are semantically equivalent to the element itself;
238 /// DeepSeek's strict validator doesn't always flatten them.
239 fn collapse_single_element_unions(schema: &mut Value) {
240 let Some(obj) = schema.as_object_mut() else {
241 return;
242 };
243 for key in ["oneOf", "allOf", "anyOf"] {
244 let single = match obj.get(key).and_then(|v| v.as_array()) {
245 Some(arr) if arr.len() == 1 => arr[0].clone(),
246 _ => continue,
247 };
248 obj.remove(key);
249 if let Value::Object(inner) = single {
250 for (k, v) in inner {
251 if !obj.contains_key(&k) {
252 obj.insert(k, v);
253 }
254 }
255 }
256 }
257 }
258
259 fn enforce_strict_subset(schema: &mut Value) {
260 if let Some(obj) = schema.as_object_mut() {
261 strip_unsupported_strict_keywords(obj);
262 if is_object_schema(obj) {
263 let originally_required = required_names(obj);
264 let properties = ensure_properties_object(obj);
265 let mut property_names: Vec<String> = properties.keys().cloned().collect();
266 property_names.sort();
267 for property_name in &property_names {
268 if !originally_required
269 .iter()
270 .any(|required| required == property_name)
271 && let Some(property_schema) = properties.get_mut(property_name)
272 {
273 mark_nullable(property_schema);
274 }
275 }
276 obj.insert(
277 "required".into(),
278 Value::Array(property_names.into_iter().map(Value::String).collect()),
279 );
280 obj.insert("additionalProperties".into(), Value::Bool(false));
281 }
282
283 for value in obj.values_mut() {
284 enforce_strict_subset(value);
285 }
286 } else if let Some(arr) = schema.as_array_mut() {
287 for value in arr {
288 enforce_strict_subset(value);
289 }
290 }
291 }
292
293 fn strip_unsupported_strict_keywords(obj: &mut Map<String, Value>) {
294 obj.remove("patternProperties");
295 match obj.get("type").and_then(Value::as_str) {
296 Some("string") => {
297 obj.remove("minLength");
298 obj.remove("maxLength");
299 }
300 Some("array") => {
301 obj.remove("minItems");
302 obj.remove("maxItems");
303 }
304 _ => {}
305 }
306 }
307
308 fn is_object_schema(obj: &Map<String, Value>) -> bool {
309 obj.get("type").and_then(Value::as_str) == Some("object") || obj.contains_key("properties")
310 }
311
312 fn ensure_properties_object(obj: &mut Map<String, Value>) -> &mut Map<String, Value> {
313 let needs_replacement = !matches!(obj.get("properties"), Some(Value::Object(_)));
314 if needs_replacement {
315 obj.insert("properties".into(), Value::Object(Map::new()));
316 }
317 obj.get_mut("properties")
318 .and_then(Value::as_object_mut)
319 .expect("properties was just ensured as object")
320 }
321
322 fn required_names(obj: &Map<String, Value>) -> Vec<String> {
323 obj.get("required")
324 .and_then(Value::as_array)
325 .map(|required| {
326 required
327 .iter()
328 .filter_map(Value::as_str)
329 .map(ToOwned::to_owned)
330 .collect()
331 })
332 .unwrap_or_default()
333 }
334
335 fn mark_nullable(schema: &mut Value) {
336 if let Some(obj) = schema.as_object_mut() {
337 obj.insert("nullable".into(), Value::Bool(true));
338 }
339 }
340
341 fn merge_root_composition_properties(obj: &mut Map<String, Value>) {
342 let mut merged = Map::new();
343 for key in ["oneOf", "anyOf", "allOf"] {
344 let Some(items) = obj.get(key).and_then(Value::as_array) else {
345 continue;
346 };
347 for item in items {
348 let Some(properties) = item.get("properties").and_then(Value::as_object) else {
349 continue;
350 };
351 for (name, schema) in properties {
352 merged.entry(name.clone()).or_insert_with(|| schema.clone());
353 }
354 }
355 }
356
357 if merged.is_empty() {
358 return;
359 }
360
361 let properties = ensure_properties_object(obj);
362 for (name, schema) in merged {
363 properties.entry(name).or_insert(schema);
364 }
365 }
366
367 fn root_composition_constraint_note(obj: &Map<String, Value>) -> Option<String> {
368 for (key, prefix) in [
369 ("oneOf", "Exactly one"),
370 ("anyOf", "At least one"),
371 ("allOf", "All"),
372 ] {
373 let Some(items) = obj.get(key).and_then(Value::as_array) else {
374 continue;
375 };
376 let mut groups: Vec<String> = items.iter().filter_map(required_group_label).collect();
377 groups.sort();
378 groups.dedup();
379 if groups.len() >= 2 {
380 return Some(format!(
381 "{prefix} of these parameter groups must be provided: {}.",
382 groups.join(" | ")
383 ));
384 }
385 }
386 None
387 }
388
389 fn required_group_label(item: &Value) -> Option<String> {
390 let mut names: Vec<String> = item
391 .get("required")?
392 .as_array()?
393 .iter()
394 .filter_map(Value::as_str)
395 .map(|name| format!("`{name}`"))
396 .collect();
397 if names.is_empty() {
398 None
399 } else {
400 names.sort();
401 names.dedup();
402 Some(names.join(" + "))
403 }
404 }
405
406 #[cfg(test)]
407 mod tests {
408 use super::*;
409 use serde_json::json;
410
411 fn test_tool(name: &str, input_schema: Value) -> Tool {
412 Tool {
413 tool_type: None,
414 name: name.to_string(),
415 description: name.to_string(),
416 input_schema,
417 allowed_callers: None,
418 defer_loading: None,
419 input_examples: None,
420 strict: None,
421 cache_control: None,
422 }
423 }
424
425 #[test]
426 fn collapses_nullable_anyof() {
427 let mut schema = json!({
428 "anyOf": [
429 {"type": "string"},
430 {"type": "null"}
431 ]
432 });
433 sanitize(&mut schema);
434 assert_eq!(schema["type"], "string");
435 assert_eq!(schema["nullable"], true);
436 assert!(schema.get("anyOf").is_none());
437 }
438
439 #[test]
440 fn collapses_nullable_oneof() {
441 let mut schema = json!({
442 "oneOf": [
443 {"type": "null"},
444 {"type": "integer", "minimum": 0}
445 ]
446 });
447 sanitize(&mut schema);
448 assert_eq!(schema["type"], "integer");
449 assert_eq!(schema["minimum"], 0);
450 assert_eq!(schema["nullable"], true);
451 }
452
453 #[test]
454 fn preserves_non_null_anyof() {
455 let original = json!({
456 "anyOf": [
457 {"type": "string"},
458 {"type": "integer"}
459 ]
460 });
461 let mut schema = original.clone();
462 sanitize(&mut schema);
463 // Multi-typed anyOf should collapse to single element after
464 // recursive walk — but here neither is null so the collapse
465 // doesn't trigger. The anyOf array itself remains.
466 assert!(schema.get("anyOf").is_some());
467 }
468
469 #[test]
470 fn injects_properties_on_bare_object() {
471 let mut schema = json!({"type": "object"});
472 sanitize(&mut schema);
473 assert!(schema.get("properties").is_some());
474 assert_eq!(schema["properties"], json!({}));
475 }
476
477 #[test]
478 fn does_not_inject_properties_when_present() {
479 let mut schema = json!({
480 "type": "object",
481 "properties": {"name": {"type": "string"}}
482 });
483 let expected = schema.clone();
484 sanitize(&mut schema);
485 assert_eq!(schema, expected);
486 }
487
488 #[test]
489 fn prunes_dangling_required() {
490 let mut schema = json!({
491 "type": "object",
492 "properties": {"name": {"type": "string"}},
493 "required": ["name", "email"]
494 });
495 sanitize(&mut schema);
496 let required = schema["required"].as_array().unwrap();
497 assert_eq!(required.len(), 1);
498 assert_eq!(required[0], "name");
499 }
500
501 #[test]
502 fn removes_required_when_all_pruned() {
503 let mut schema = json!({
504 "type": "object",
505 "properties": {},
506 "required": ["ghost"]
507 });
508 sanitize(&mut schema);
509 assert!(schema.get("required").is_none());
510 }
511
512 #[test]
513 fn collapses_single_element_oneof() {
514 let mut schema = json!({
515 "oneOf": [{"type": "string", "minLength": 1}]
516 });
517 sanitize(&mut schema);
518 assert!(schema.get("oneOf").is_none());
519 assert_eq!(schema["type"], "string");
520 assert_eq!(schema["minLength"], 1);
521 }
522
523 #[test]
524 fn collapses_single_element_anyof() {
525 let mut schema = json!({
526 "anyOf": [{"type": "boolean"}]
527 });
528 sanitize(&mut schema);
529 assert!(schema.get("anyOf").is_none());
530 assert_eq!(schema["type"], "boolean");
531 }
532
533 #[test]
534 fn recursive_walk_into_properties() {
535 let mut schema = json!({
536 "type": "object",
537 "properties": {
538 "opt_name": {
539 "anyOf": [
540 {"type": "string"},
541 {"type": "null"}
542 ]
543 }
544 }
545 });
546 sanitize(&mut schema);
547 let prop = &schema["properties"]["opt_name"];
548 assert_eq!(prop["type"], "string");
549 assert_eq!(prop["nullable"], true);
550 }
551
552 #[test]
553 fn recursive_walk_into_items() {
554 let mut schema = json!({
555 "type": "array",
556 "items": {
557 "anyOf": [
558 {"type": "integer"},
559 {"type": "null"}
560 ]
561 }
562 });
563 sanitize(&mut schema);
564 let items = &schema["items"];
565 assert_eq!(items["type"], "integer");
566 assert_eq!(items["nullable"], true);
567 }
568
569 #[test]
570 fn nested_anyof_in_nullable_union_stays_structural() {
571 // Pydantic can nest unions: Optional[Union[str, int]].
572 let mut schema = json!({
573 "anyOf": [
574 {
575 "anyOf": [
576 {"type": "string"},
577 {"type": "integer"}
578 ]
579 },
580 {"type": "null"}
581 ]
582 });
583 sanitize(&mut schema);
584 // The non-null branch has no concrete type of its own. Collapsing the
585 // outer union would manufacture an annotation-only `nullable` schema,
586 // so retain both levels and their explicit null termination.
587 assert!(schema.get("nullable").is_none());
588 assert_eq!(schema["anyOf"][1], json!({"type": "null"}));
589 assert_eq!(
590 schema["anyOf"][0]["anyOf"],
591 json!([{"type": "string"}, {"type": "integer"}])
592 );
593 }
594
595 #[test]
596 fn idempotent() {
597 let mut schema = json!({
598 "type": "object",
599 "properties": {
600 "name": {"type": "string"},
601 "maybe": {
602 "anyOf": [{"type": "integer"}, {"type": "null"}]
603 }
604 },
605 "required": ["name", "missing_field"]
606 });
607 sanitize(&mut schema);
608 let after_first = schema.clone();
609 sanitize(&mut schema);
610 assert_eq!(schema, after_first, "sanitize must be idempotent");
611 }
612
613 #[test]
614 fn strict_sanitize_requires_all_object_properties_and_closes_extra_keys() {
615 let mut schema = json!({
616 "type": "object",
617 "properties": {
618 "name": {"type": "string"},
619 "count": {"type": "integer"}
620 },
621 "required": ["name"],
622 "additionalProperties": {"type": "string"}
623 });
624
625 sanitize_for_strict(&mut schema);
626
627 assert_eq!(schema["additionalProperties"], false);
628 assert_eq!(schema["required"], json!(["count", "name"]));
629 assert_eq!(schema["properties"]["count"]["nullable"], true);
630 assert!(schema["properties"]["name"].get("nullable").is_none());
631 }
632
633 #[test]
634 fn strict_sanitize_preserves_optional_properties_as_nullable() {
635 let mut schema = json!({
636 "type": "object",
637 "properties": {
638 "path": {"type": "string"},
639 "start_line": {"type": "integer"},
640 "max_lines": {"type": "integer"},
641 "options": {
642 "type": "object",
643 "properties": {
644 "encoding": {"type": "string"},
645 "trim": {"type": "boolean"}
646 },
647 "required": ["encoding"]
648 }
649 },
650 "required": ["path", "options"]
651 });
652
653 sanitize_for_strict(&mut schema);
654
655 assert_eq!(
656 schema["required"],
657 json!(["max_lines", "options", "path", "start_line"])
658 );
659 assert!(schema["properties"]["path"].get("nullable").is_none());
660 assert!(schema["properties"]["options"].get("nullable").is_none());
661 assert_eq!(schema["properties"]["start_line"]["nullable"], true);
662 assert_eq!(schema["properties"]["max_lines"]["nullable"], true);
663 assert_eq!(
664 schema["properties"]["options"]["required"],
665 json!(["encoding", "trim"])
666 );
667 assert!(
668 schema["properties"]["options"]["properties"]["encoding"]
669 .get("nullable")
670 .is_none()
671 );
672 assert_eq!(
673 schema["properties"]["options"]["properties"]["trim"]["nullable"],
674 true
675 );
676 }
677
678 #[test]
679 fn strict_sanitize_applies_object_rules_recursively() {
680 let mut schema = json!({
681 "type": "object",
682 "properties": {
683 "outer": {
684 "type": "object",
685 "properties": {
686 "inner": {"type": "string"}
687 },
688 "required": []
689 }
690 },
691 "required": []
692 });
693
694 sanitize_for_strict(&mut schema);
695
696 assert_eq!(schema["required"], json!(["outer"]));
697 assert_eq!(schema["additionalProperties"], false);
698 assert_eq!(schema["properties"]["outer"]["required"], json!(["inner"]));
699 assert_eq!(schema["properties"]["outer"]["additionalProperties"], false);
700 }
701
702 #[test]
703 fn strict_sanitize_removes_unsupported_string_and_array_bounds() {
704 let mut schema = json!({
705 "type": "object",
706 "properties": {
707 "name": {
708 "type": "string",
709 "minLength": 1,
710 "maxLength": 64,
711 "pattern": "^[a-z]+$"
712 },
713 "items": {
714 "type": "array",
715 "minItems": 1,
716 "maxItems": 5,
717 "items": {"type": "string"}
718 },
719 "score": {
720 "type": "integer",
721 "minimum": 1,
722 "maximum": 5
723 }
724 }
725 });
726
727 sanitize_for_strict(&mut schema);
728
729 let name = &schema["properties"]["name"];
730 assert!(name.get("minLength").is_none());
731 assert!(name.get("maxLength").is_none());
732 assert_eq!(name["pattern"], "^[a-z]+$");
733
734 let items = &schema["properties"]["items"];
735 assert!(items.get("minItems").is_none());
736 assert!(items.get("maxItems").is_none());
737
738 let score = &schema["properties"]["score"];
739 assert_eq!(score["minimum"], 1);
740 assert_eq!(score["maximum"], 5);
741 }
742
743 #[test]
744 fn strict_mode_applies_per_tool_in_mixed_catalog() {
745 let mut tools = vec![
746 test_tool(
747 "lookup",
748 json!({
749 "type": "object",
750 "properties": {
751 "query": {"type": "string"}
752 },
753 "required": []
754 }),
755 ),
756 test_tool(
757 "either",
758 json!({
759 "type": "object",
760 "properties": {
761 "a": {"type": "string"},
762 "b": {"type": "string"}
763 },
764 "anyOf": [
765 {"required": ["a"]},
766 {"required": ["b"]}
767 ]
768 }),
769 ),
770 test_tool(
771 "nested",
772 json!({
773 "type": "object",
774 "properties": {
775 "value": {
776 "oneOf": [
777 {"type": "string"},
778 {"type": "integer"}
779 ]
780 }
781 }
782 }),
783 ),
784 ];
785
786 assert!(!prepare_tools_for_strict_mode(&mut tools));
787 assert_eq!(tools[0].strict, Some(true));
788 assert_eq!(tools[0].input_schema["required"], json!(["query"]));
789 assert_eq!(tools[0].input_schema["additionalProperties"], false);
790 assert_eq!(tools[1].strict, None);
791 assert!(tools[1].input_schema.get("anyOf").is_some());
792 assert_eq!(tools[2].strict, None);
793 assert!(
794 tools[2].input_schema["properties"]["value"]
795 .get("oneOf")
796 .is_some()
797 );
798 }
799
800 #[test]
801 fn strict_mode_rejects_nested_unsupported_composition() {
802 let mut tools = vec![Tool {
803 tool_type: None,
804 name: "nested".to_string(),
805 description: "Nested oneOf".to_string(),
806 input_schema: json!({
807 "type": "object",
808 "properties": {
809 "value": {
810 "oneOf": [
811 {"type": "string"},
812 {"type": "integer"}
813 ]
814 }
815 }
816 }),
817 allowed_callers: None,
818 defer_loading: None,
819 input_examples: None,
820 strict: None,
821 cache_control: None,
822 }];
823
824 assert!(!prepare_tools_for_strict_mode(&mut tools));
825 assert_eq!(tools[0].strict, None);
826 }
827
828 #[test]
829 fn strict_mode_marks_compatible_tools_strict() {
830 let mut tools = vec![Tool {
831 tool_type: None,
832 name: "lookup".to_string(),
833 description: "Lookup".to_string(),
834 input_schema: json!({
835 "type": "object",
836 "properties": {
837 "query": {"type": "string"}
838 },
839 "required": []
840 }),
841 allowed_callers: None,
842 defer_loading: None,
843 input_examples: None,
844 strict: None,
845 cache_control: None,
846 }];
847
848 assert!(prepare_tools_for_strict_mode(&mut tools));
849 assert_eq!(tools[0].strict, Some(true));
850 assert_eq!(tools[0].input_schema["required"], json!(["query"]));
851 assert_eq!(tools[0].input_schema["additionalProperties"], false);
852 }
853
854 #[test]
855 fn responses_sanitize_removes_root_composition_from_apply_patch_shape() {
856 let mut schema = json!({
857 "type": "object",
858 "properties": {
859 "path": {"type": "string"},
860 "patch": {"type": "string"},
861 "replace": {
862 "type": "array",
863 "items": {
864 "type": "object",
865 "properties": {
866 "path": {"type": "string"},
867 "content": {"type": "string"}
868 },
869 "required": ["path", "content"]
870 }
871 },
872 "changes": {
873 "type": "array",
874 "items": {
875 "type": "object",
876 "properties": {
877 "path": {"type": "string"},
878 "content": {"type": "string"}
879 },
880 "required": ["path", "content"]
881 }
882 }
883 },
884 "oneOf": [
885 {"required": ["patch"]},
886 {"required": ["replace"]},
887 {"required": ["changes"]}
888 ]
889 });
890
891 let note = sanitize_for_responses(&mut schema);
892
893 assert_eq!(schema["type"], "object");
894 assert!(schema.get("oneOf").is_none());
895 assert!(schema.get("anyOf").is_none());
896 assert!(schema.get("allOf").is_none());
897 assert!(schema.get("enum").is_none());
898 assert!(schema.get("not").is_none());
899 assert!(schema["properties"].get("patch").is_some());
900 assert!(schema["properties"].get("replace").is_some());
901 assert!(schema["properties"].get("changes").is_some());
902 assert_eq!(
903 note.as_deref(),
904 Some(
905 "Exactly one of these parameter groups must be provided: `changes` | `patch` | `replace`."
906 )
907 );
908 }
909
910 #[test]
911 fn responses_sanitize_merges_root_alternative_properties() {
912 let mut schema = json!({
913 "anyOf": [
914 {
915 "type": "object",
916 "properties": {
917 "path": {"type": "string"}
918 },
919 "required": ["path"]
920 },
921 {
922 "type": "object",
923 "properties": {
924 "url": {"type": "string"}
925 },
926 "required": ["url"]
927 }
928 ]
929 });
930
931 let note = sanitize_for_responses(&mut schema);
932
933 assert_eq!(schema["type"], "object");
934 assert!(schema.get("anyOf").is_none());
935 assert!(schema["properties"].get("path").is_some());
936 assert!(schema["properties"].get("url").is_some());
937 assert!(schema.get("required").is_none());
938 assert_eq!(
939 note.as_deref(),
940 Some("At least one of these parameter groups must be provided: `path` | `url`.")
941 );
942 }
943
944 #[test]
945 fn responses_sanitize_preserves_nested_alternatives() {
946 let mut schema = json!({
947 "type": "object",
948 "properties": {
949 "value": {
950 "anyOf": [
951 {"type": "string"},
952 {"type": "integer"}
953 ]
954 }
955 }
956 });
957
958 let note = sanitize_for_responses(&mut schema);
959
960 assert_eq!(schema["type"], "object");
961 assert!(schema.get("anyOf").is_none());
962 assert!(schema["properties"]["value"].get("anyOf").is_some());
963 assert_eq!(note, None);
964 }
965
966 #[test]
967 fn xai_sanitize_flattens_apply_patch_root_one_of() {
968 // The exact shape that produced the live 400:
969 // "apply_patch: tool parameter root must be an object type (root
970 // schema is an anyOf/oneOf union with a non-object branch)".
971 use crate::tools::spec::ToolSpec as _;
972 let mut schema = crate::tools::apply_patch::ApplyPatchTool.input_schema();
973 assert!(schema.get("oneOf").is_some(), "fixture must match the tool");
974
975 let note = sanitize_for_xai_parameters(&mut schema);
976
977 assert_eq!(schema["type"], "object");
978 assert!(schema.get("oneOf").is_none());
979 assert!(schema.get("anyOf").is_none());
980 assert!(schema["properties"].get("patch").is_some());
981 assert!(schema["properties"].get("changes").is_some());
982 assert_eq!(
983 note.as_deref(),
984 Some(
985 "Exactly one of these parameter groups must be provided: `changes` | `patch` | `replace`."
986 )
987 );
988 }
989
990 #[test]
991 fn responses_sanitize_plain_object_has_no_constraint_note() {
992 let mut schema = json!({
993 "type": "object",
994 "properties": {
995 "query": {"type": "string"}
996 }
997 });
998
999 let note = sanitize_for_responses(&mut schema);
1000
1001 assert_eq!(schema["type"], "object");
1002 assert_eq!(note, None);
1003 }
1004
1005 #[test]
1006 fn responses_constraint_note_is_sorted_and_deduped() {
1007 let mut schema = json!({
1008 "type": "object",
1009 "properties": {
1010 "a": {"type": "string"},
1011 "b": {"type": "string"},
1012 "c": {"type": "string"}
1013 },
1014 "oneOf": [
1015 {"required": ["b", "a", "a"]},
1016 {"required": ["c"]},
1017 {"required": ["a", "b"]}
1018 ]
1019 });
1020
1021 let note = sanitize_for_responses(&mut schema);
1022
1023 assert_eq!(
1024 note.as_deref(),
1025 Some("Exactly one of these parameter groups must be provided: `a` + `b` | `c`.")
1026 );
1027 }
1028 }
1029
1030 /// Normalize a tool's function schema for Kimi / Moonshot API compatibility.
1031 ///
1032 /// Kimi's API enforces stricter JSON Schema validation: when a schema uses
1033 /// `anyOf` / `oneOf`, the `type` field must be placed inside each item rather
1034 /// than on the parent object. This function walks the schema root and any
1035 /// nested objects, pushing `"type": "object"` down into `anyOf` / `oneOf`
1036 /// items when present.
1037 ///
1038 /// Invariant: only mutates objects that carry a top-level `type` + an
1039 /// `anyOf` or `oneOf` array — pure schemas without conditional alternatives
1040 /// are left untouched.
1041 pub fn sanitize_for_kimi(schema: &mut serde_json::Value) {
1042 if let Some(obj) = schema.as_object_mut() {
1043 // Recurse first so a type injected into this object's alternatives is
1044 // not immediately removed again by processing that freshly-mutated item.
1045 for map_key in ["properties", "$defs"] {
1046 if let Some(children) = obj.get_mut(map_key).and_then(Value::as_object_mut) {
1047 for child in children.values_mut() {
1048 sanitize_for_kimi(child);
1049 }
1050 }
1051 }
1052 if let Some(items) = obj.get_mut("items") {
1053 sanitize_for_kimi(items);
1054 }
1055 if let Some(additional) = obj.get_mut("additionalProperties")
1056 && additional.is_object()
1057 {
1058 sanitize_for_kimi(additional);
1059 }
1060 for union_key in ["anyOf", "oneOf"] {
1061 if let Some(branches) = obj.get_mut(union_key).and_then(Value::as_array_mut) {
1062 for branch in branches {
1063 sanitize_for_kimi(branch);
1064 }
1065 }
1066 }
1067
1068 // If this object has `type` + `anyOf`/`oneOf`, push `type` into
1069 // each item and remove it from the parent. Otherwise leave it alone.
1070 let should_push =
1071 obj.contains_key("type") && (obj.contains_key("anyOf") || obj.contains_key("oneOf"));
1072 if should_push && let Some(type_val) = obj.remove("type") {
1073 for key in ["anyOf", "oneOf"] {
1074 if let Some(items) = obj.get_mut(key).and_then(|v| v.as_array_mut()) {
1075 for item in items {
1076 if let Some(item_obj) = item.as_object_mut()
1077 && !item_obj.contains_key("type")
1078 {
1079 item_obj.insert("type".to_string(), type_val.clone());
1080 }
1081 }
1082 }
1083 }
1084 // The provider-neutral sanitizer injects an empty `properties`
1085 // map on every bare object before this provider pass. MFJS permits
1086 // only annotations beside `anyOf`, so remove that semantic no-op
1087 // after moving the object type into each branch.
1088 if obj
1089 .get("properties")
1090 .and_then(Value::as_object)
1091 .is_some_and(Map::is_empty)
1092 {
1093 obj.remove("properties");
1094 }
1095 }
1096 }
1097 }
1098
1099 /// A safe, provider-facing reason that Kimi parameters could not be emitted.
1100 ///
1101 /// These diagnostics deliberately never include the schema or `$ref` value:
1102 /// tool schemas can be supplied by MCP servers and may contain private data.
1103 #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
1104 pub enum KimiParameterSchemaError {
1105 #[error("Moonshot function parameters root must be a JSON object schema")]
1106 RootMustBeObject,
1107 #[error("Moonshot function parameters contain an unsupported root reference")]
1108 UnsupportedRootReference,
1109 #[error("Moonshot function parameters contain an unresolved internal root reference")]
1110 UnresolvedRootReference,
1111 #[error("Moonshot function parameters contain a cyclic internal root reference")]
1112 CyclicRootReference,
1113 #[error("Moonshot function parameters root reference must resolve to an object schema")]
1114 ReferencedRootMustBeObject,
1115 #[error("Moonshot function parameters contain unsupported nested allOf composition")]
1116 UnsupportedNestedAllOf,
1117 #[error("Moonshot function parameters contain conflicting nested union composition")]
1118 ConflictingNestedUnion,
1119 #[error("Moonshot function parameters contain an unsupported const literal")]
1120 UnsupportedConstLiteral,
1121 #[error("Moonshot function parameters contain conflicting literal constraints")]
1122 ConflictingLiteralConstraint,
1123 #[error("Moonshot function parameters contain an invalid nullable marker")]
1124 InvalidNullable,
1125 #[error("Moonshot function parameters contain an unsupported MFJS keyword")]
1126 UnsupportedKeyword,
1127 #[error("Moonshot function parameters contain an invalid MFJS schema node")]
1128 InvalidSchemaNode,
1129 #[error("Moonshot function parameters contain an invalid MFJS keyword value")]
1130 InvalidKeywordValue,
1131 #[error("Moonshot function parameters contain an invalid MFJS reference")]
1132 InvalidReference,
1133 #[error("Moonshot function parameters contain an MFJS schema without a concrete type")]
1134 MissingType,
1135 #[error("Moonshot function parameters exceed an MFJS resource limit")]
1136 ResourceLimitExceeded,
1137 #[error("Moonshot function parameters contain a non-terminating MFJS reference")]
1138 NonTerminatingReference,
1139 #[error("Moonshot function parameters contain an invalid MFJS default")]
1140 InvalidDefault,
1141 #[error("Moonshot function parameters contain an invalid MFJS range")]
1142 InvalidRange,
1143 }
1144
1145 /// Normalize a complete Kimi / Moonshot `function.parameters` object.
1146 ///
1147 /// Function parameters have an additional MFJS constraint: the root must end
1148 /// as a plain `type: "object"` schema. Root composition is flattened using the
1149 /// same compatibility pass as Responses and xAI, while supported nested
1150 /// `anyOf` branches remain nested. Internal root `$ref` values are resolved and
1151 /// inlined before normalization so we never manufacture the invalid
1152 /// `type + allOf($ref)` shape rejected by MFJS.
1153 ///
1154 /// Unsupported, unresolved, cyclic, and non-object root references fail
1155 /// closed with a non-secret diagnostic instead of being sent to Moonshot.
1156 ///
1157 /// MFJS differences from JSON Schema:
1158 /// <https://github.com/MoonshotAI/walle/blob/main/docs/mfjs-walle-vs-draft-2020-12.md>
1159 pub fn sanitize_for_kimi_parameters(
1160 parameters: &mut Value,
1161 ) -> Result<Option<String>, KimiParameterSchemaError> {
1162 // Work on a clone so a rejected schema remains byte-for-byte unchanged for
1163 // callers that retain the catalog and retry against another provider.
1164 let mut candidate = parameters.clone();
1165 let constraint_note = sanitize_kimi_parameters_candidate(&mut candidate)?;
1166 validate_mfjs_parameters(&candidate)?;
1167 *parameters = candidate;
1168 Ok(constraint_note)
1169 }
1170
1171 fn sanitize_kimi_parameters_candidate(
1172 parameters: &mut Value,
1173 ) -> Result<Option<String>, KimiParameterSchemaError> {
1174 let Some(root) = parameters.as_object() else {
1175 return Err(KimiParameterSchemaError::RootMustBeObject);
1176 };
1177 if root
1178 .get("type")
1179 .is_some_and(|schema_type| schema_type != "object")
1180 {
1181 return Err(KimiParameterSchemaError::RootMustBeObject);
1182 }
1183
1184 inline_internal_kimi_root_ref(parameters)?;
1185
1186 // A function schema's root cannot carry MFJS composition because it must
1187 // simultaneously be `type: object`. Flatten the actual root shape used by
1188 // apply_patch and retain its dropped required-group contract as a prompt
1189 // note for the model.
1190 let constraint_note = sanitize_for_responses(parameters);
1191
1192 // Restore nullable unions collapsed by the registry's provider-neutral
1193 // sanitizer, translate MFJS-safe scalar const values, and normalize nested
1194 // composition. Codewhale still validates tool input before execution, so
1195 // widening oneOf to MFJS's anyOf remains safe; allOf fails closed.
1196 normalize_kimi_compatibility(parameters, true)?;
1197
1198 // MFJS requires `type` to live inside each anyOf branch, never alongside
1199 // the union keyword. The root is composition-free at this point, so this
1200 // only adjusts valid nested unions.
1201 sanitize_for_kimi(parameters);
1202
1203 let Some(root) = parameters.as_object() else {
1204 return Err(KimiParameterSchemaError::RootMustBeObject);
1205 };
1206 if root.get("type").and_then(Value::as_str) != Some("object")
1207 || root.contains_key("anyOf")
1208 || root.contains_key("oneOf")
1209 || root.contains_key("allOf")
1210 || root.contains_key("$ref")
1211 {
1212 return Err(KimiParameterSchemaError::RootMustBeObject);
1213 }
1214
1215 Ok(constraint_note)
1216 }
1217
1218 fn inline_internal_kimi_root_ref(parameters: &mut Value) -> Result<(), KimiParameterSchemaError> {
1219 let document = parameters.clone();
1220 let Some(document_root) = document.as_object() else {
1221 return Err(KimiParameterSchemaError::RootMustBeObject);
1222 };
1223 let Some(root_ref) = document_root.get("$ref") else {
1224 return Ok(());
1225 };
1226 let Some(mut reference) = root_ref.as_str() else {
1227 return Err(KimiParameterSchemaError::UnsupportedRootReference);
1228 };
1229
1230 let mut visited = HashSet::new();
1231 let resolved = loop {
1232 if !reference.starts_with("#/") {
1233 return Err(KimiParameterSchemaError::UnsupportedRootReference);
1234 }
1235 if !visited.insert(reference.to_string()) {
1236 return Err(KimiParameterSchemaError::CyclicRootReference);
1237 }
1238 let target = document
1239 .pointer(&reference[1..])
1240 .ok_or(KimiParameterSchemaError::UnresolvedRootReference)?;
1241 let target = target
1242 .as_object()
1243 .ok_or(KimiParameterSchemaError::ReferencedRootMustBeObject)?;
1244 if let Some(next_ref) = target.get("$ref") {
1245 reference = next_ref
1246 .as_str()
1247 .ok_or(KimiParameterSchemaError::UnsupportedRootReference)?;
1248 continue;
1249 }
1250 if target.get("type").and_then(Value::as_str) != Some("object") {
1251 return Err(KimiParameterSchemaError::ReferencedRootMustBeObject);
1252 }
1253 break target.clone();
1254 };
1255
1256 let mut inlined = resolved;
1257 for (key, value) in document_root {
1258 if key != "$ref" {
1259 inlined.insert(key.clone(), value.clone());
1260 }
1261 }
1262 *parameters = Value::Object(inlined);
1263 Ok(())
1264 }
1265
1266 fn normalize_kimi_compatibility(
1267 schema: &mut Value,
1268 is_root: bool,
1269 ) -> Result<(), KimiParameterSchemaError> {
1270 let Some(obj) = schema.as_object_mut() else {
1271 return Err(KimiParameterSchemaError::InvalidSchemaNode);
1272 };
1273
1274 if !is_root {
1275 if obj.contains_key("allOf") {
1276 return Err(KimiParameterSchemaError::UnsupportedNestedAllOf);
1277 }
1278 if let Some(one_of) = obj.remove("oneOf") {
1279 if obj.contains_key("anyOf") {
1280 return Err(KimiParameterSchemaError::ConflictingNestedUnion);
1281 }
1282 obj.insert("anyOf".to_string(), one_of);
1283 }
1284 }
1285
1286 if let Some(constant) = obj.remove("const") {
1287 if !is_mfjs_enum_literal(&constant) {
1288 return Err(KimiParameterSchemaError::UnsupportedConstLiteral);
1289 }
1290 if !obj.contains_key("type") {
1291 let inferred = mfjs_literal_kind(&constant)
1292 .ok_or(KimiParameterSchemaError::UnsupportedConstLiteral)?;
1293 let schema_type = match inferred {
1294 MfjsLiteralKind::Integer => "integer",
1295 MfjsLiteralKind::Number => "number",
1296 MfjsLiteralKind::String => "string",
1297 };
1298 obj.insert("type".to_string(), Value::String(schema_type.to_string()));
1299 }
1300 if let Some(existing) = obj.get("enum") {
1301 let agrees = existing
1302 .as_array()
1303 .is_some_and(|values| values.as_slice() == [constant.clone()]);
1304 if !agrees {
1305 return Err(KimiParameterSchemaError::ConflictingLiteralConstraint);
1306 }
1307 } else {
1308 obj.insert("enum".to_string(), Value::Array(vec![constant]));
1309 }
1310 }
1311
1312 let nullable = obj.remove("nullable");
1313 if nullable.is_some()
1314 && !obj
1315 .get("type")
1316 .is_some_and(|schema_type| schema_type.as_str().is_some_and(is_mfjs_concrete_type))
1317 {
1318 return Err(KimiParameterSchemaError::InvalidNullable);
1319 }
1320 match nullable.as_ref().map(Value::as_bool) {
1321 None => {}
1322 Some(Some(false)) => {}
1323 Some(Some(true)) if is_root => {
1324 // Function parameters are required to be an object at the root;
1325 // null was never a valid wire instance there.
1326 }
1327 Some(Some(true)) => {
1328 let non_null = Value::Object(std::mem::take(obj));
1329 *schema = serde_json::json!({
1330 "anyOf": [non_null, {"type": "null"}]
1331 });
1332 }
1333 Some(None) => return Err(KimiParameterSchemaError::InvalidNullable),
1334 }
1335
1336 normalize_kimi_child_schemas(schema)?;
1337 Ok(())
1338 }
1339
1340 fn normalize_kimi_child_schemas(schema: &mut Value) -> Result<(), KimiParameterSchemaError> {
1341 let Some(obj) = schema.as_object_mut() else {
1342 return Err(KimiParameterSchemaError::InvalidSchemaNode);
1343 };
1344
1345 for map_key in ["properties", "$defs"] {
1346 if let Some(children) = obj.get_mut(map_key).and_then(Value::as_object_mut) {
1347 for child in children.values_mut() {
1348 normalize_kimi_compatibility(child, false)?;
1349 }
1350 }
1351 }
1352
1353 if let Some(items) = obj.get_mut("items") {
1354 normalize_kimi_compatibility(items, false)?;
1355 }
1356 if let Some(additional) = obj.get_mut("additionalProperties")
1357 && additional.is_object()
1358 {
1359 normalize_kimi_compatibility(additional, false)?;
1360 }
1361 if let Some(branches) = obj.get_mut("anyOf").and_then(Value::as_array_mut) {
1362 for branch in branches {
1363 normalize_kimi_compatibility(branch, false)?;
1364 }
1365 }
1366 Ok(())
1367 }
1368
1369 fn is_mfjs_enum_literal(value: &Value) -> bool {
1370 value.is_string() || value.is_number()
1371 }
1372
1373 /// Validate one fully normalized MFJS function-parameters schema.
1374 ///
1375 /// Every error is a fixed enum variant: schemas can originate in MCP or
1376 /// runtime tools and may contain private names or values, so diagnostics must
1377 /// never echo a keyword, property, reference, or literal from the document.
1378 pub fn validate_mfjs_parameters(parameters: &Value) -> Result<(), KimiParameterSchemaError> {
1379 let root = parameters
1380 .as_object()
1381 .ok_or(KimiParameterSchemaError::RootMustBeObject)?;
1382 if root.get("type").and_then(Value::as_str) != Some("object")
1383 || root.contains_key("anyOf")
1384 || root.contains_key("oneOf")
1385 || root.contains_key("allOf")
1386 || root.contains_key("$ref")
1387 {
1388 return Err(KimiParameterSchemaError::RootMustBeObject);
1389 }
1390 if serde_json::to_vec(parameters)
1391 .map(|encoded| encoded.len() > MFJS_MAX_SCHEMA_BYTES)
1392 .unwrap_or(true)
1393 {
1394 return Err(KimiParameterSchemaError::ResourceLimitExceeded);
1395 }
1396
1397 let mut state = MfjsValidationState::new(parameters);
1398 state.validate_schema(parameters, true, false, 0, 0)?;
1399
1400 let mut visiting_refs = HashSet::new();
1401 if !mfjs_schema_can_terminate(parameters, parameters, &mut visiting_refs, 0)? {
1402 return Err(KimiParameterSchemaError::NonTerminatingReference);
1403 }
1404
1405 validate_mfjs_expanded_depth(parameters, parameters, 0, &mut HashSet::new(), 0)?;
1406 if let Some(definitions) = root.get("$defs").and_then(Value::as_object) {
1407 for definition in definitions.values() {
1408 validate_mfjs_expanded_depth(definition, parameters, 0, &mut HashSet::new(), 0)?;
1409 }
1410 }
1411 Ok(())
1412 }
1413
1414 const MFJS_MAX_ANY_OF_ITEMS: usize = 10;
1415 const MFJS_MAX_OBJECT_DEPTH: usize = 5;
1416 const MFJS_MAX_TOTAL_PROPERTIES: usize = 100;
1417 const MFJS_MAX_TOTAL_ENUM_VALUES: usize = 500;
1418 const MFJS_ENUM_LENGTH_CHECK_THRESHOLD: usize = 250;
1419 const MFJS_MAX_ENUM_STRING_LENGTH: usize = 7_500;
1420 const MFJS_MAX_SCHEMA_BYTES: usize = 120_000;
1421 const MFJS_MAX_STRUCTURAL_DEPTH: usize = 64;
1422 const MFJS_MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
1423
1424 struct MfjsValidationState<'a> {
1425 document: &'a Value,
1426 total_properties: usize,
1427 total_enum_values: usize,
1428 }
1429
1430 impl<'a> MfjsValidationState<'a> {
1431 fn new(document: &'a Value) -> Self {
1432 Self {
1433 document,
1434 total_properties: 0,
1435 total_enum_values: 0,
1436 }
1437 }
1438
1439 fn validate_schema(
1440 &mut self,
1441 schema: &Value,
1442 is_root: bool,
1443 allow_empty: bool,
1444 property_depth: usize,
1445 structural_depth: usize,
1446 ) -> Result<(), KimiParameterSchemaError> {
1447 if structural_depth > MFJS_MAX_STRUCTURAL_DEPTH {
1448 return Err(KimiParameterSchemaError::ResourceLimitExceeded);
1449 }
1450 let obj = schema
1451 .as_object()
1452 .ok_or(KimiParameterSchemaError::InvalidSchemaNode)?;
1453 if obj.is_empty() {
1454 return if allow_empty {
1455 Ok(())
1456 } else {
1457 Err(KimiParameterSchemaError::MissingType)
1458 };
1459 }
1460
1461 const ALLOWED_KEYWORDS: &[&str] = &[
1462 "$id",
1463 "$ref",
1464 "$defs",
1465 "anyOf",
1466 "properties",
1467 "additionalProperties",
1468 "items",
1469 "type",
1470 "enum",
1471 "required",
1472 "maxLength",
1473 "minLength",
1474 "maximum",
1475 "minimum",
1476 "maxItems",
1477 "minItems",
1478 "title",
1479 "description",
1480 "default",
1481 ];
1482 if obj
1483 .keys()
1484 .any(|keyword| !ALLOWED_KEYWORDS.contains(&keyword.as_str()))
1485 {
1486 return Err(KimiParameterSchemaError::UnsupportedKeyword);
1487 }
1488
1489 for annotation in ["title", "description"] {
1490 if obj.get(annotation).is_some_and(|value| !value.is_string()) {
1491 return Err(KimiParameterSchemaError::InvalidKeywordValue);
1492 }
1493 }
1494 if obj.get("$id").is_some_and(|value| !value.is_string())
1495 || (!is_root && obj.contains_key("$id"))
1496 {
1497 return Err(KimiParameterSchemaError::InvalidKeywordValue);
1498 }
1499
1500 if let Some(definitions) = obj.get("$defs") {
1501 if !is_root {
1502 return Err(KimiParameterSchemaError::InvalidKeywordValue);
1503 }
1504 let definitions = definitions
1505 .as_object()
1506 .ok_or(KimiParameterSchemaError::InvalidKeywordValue)?;
1507 for (name, definition) in definitions {
1508 if name.is_empty() || name.contains('/') {
1509 return Err(KimiParameterSchemaError::InvalidKeywordValue);
1510 }
1511 self.validate_schema(definition, false, false, 0, structural_depth + 1)?;
1512 }
1513 }
1514
1515 if let Some(reference) = obj.get("$ref") {
1516 let reference = reference
1517 .as_str()
1518 .ok_or(KimiParameterSchemaError::InvalidReference)?;
1519 if resolve_mfjs_reference(self.document, reference).is_none() {
1520 return Err(KimiParameterSchemaError::InvalidReference);
1521 }
1522 let allowed_ref_sibling = |key: &str| {
1523 matches!(key, "$ref" | "title" | "description")
1524 || (is_root && matches!(key, "$defs" | "$id"))
1525 };
1526 if obj.keys().any(|key| !allowed_ref_sibling(key)) {
1527 return Err(KimiParameterSchemaError::InvalidReference);
1528 }
1529 return Ok(());
1530 }
1531
1532 if let Some(any_of) = obj.get("anyOf") {
1533 let branches = any_of
1534 .as_array()
1535 .filter(|branches| !branches.is_empty() && branches.len() <= MFJS_MAX_ANY_OF_ITEMS)
1536 .ok_or(KimiParameterSchemaError::ResourceLimitExceeded)?;
1537 let allowed_union_sibling = |key: &str| {
1538 matches!(key, "anyOf" | "title" | "description")
1539 || (is_root && matches!(key, "$defs" | "$id"))
1540 };
1541 if obj.keys().any(|key| !allowed_union_sibling(key)) {
1542 return Err(KimiParameterSchemaError::InvalidKeywordValue);
1543 }
1544 for branch in branches {
1545 self.validate_schema(branch, false, false, property_depth, structural_depth + 1)?;
1546 }
1547 return Ok(());
1548 }
1549
1550 let schema_type = obj
1551 .get("type")
1552 .and_then(Value::as_str)
1553 .filter(|schema_type| is_mfjs_concrete_type(schema_type))
1554 .ok_or(KimiParameterSchemaError::MissingType)?;
1555 if obj
1556 .keys()
1557 .any(|keyword| !mfjs_keyword_allowed_for_type(keyword, schema_type, is_root))
1558 {
1559 return Err(KimiParameterSchemaError::InvalidKeywordValue);
1560 }
1561
1562 if let Some(values) = obj.get("enum") {
1563 validate_mfjs_enum(values, schema_type, self)?;
1564 }
1565 if let Some(default) = obj.get("default") {
1566 validate_mfjs_default(default, schema_type)?;
1567 }
1568
1569 if let Some(properties) = obj.get("properties") {
1570 let properties = properties
1571 .as_object()
1572 .ok_or(KimiParameterSchemaError::InvalidKeywordValue)?;
1573 self.total_properties = self
1574 .total_properties
1575 .checked_add(properties.len())
1576 .ok_or(KimiParameterSchemaError::ResourceLimitExceeded)?;
1577 if self.total_properties > MFJS_MAX_TOTAL_PROPERTIES {
1578 return Err(KimiParameterSchemaError::ResourceLimitExceeded);
1579 }
1580 for (name, property) in properties {
1581 if name.is_empty()
1582 || matches!(
1583 name.as_str(),
1584 "$defs" | "$ref" | "anyOf" | "required" | "additionalProperties"
1585 )
1586 {
1587 return Err(KimiParameterSchemaError::InvalidKeywordValue);
1588 }
1589 let child_depth = property_depth
1590 .checked_add(1)
1591 .ok_or(KimiParameterSchemaError::ResourceLimitExceeded)?;
1592 if child_depth > MFJS_MAX_OBJECT_DEPTH {
1593 return Err(KimiParameterSchemaError::ResourceLimitExceeded);
1594 }
1595 self.validate_schema(property, false, false, child_depth, structural_depth + 1)?;
1596 }
1597 }
1598
1599 if let Some(required) = obj.get("required") {
1600 let required = required
1601 .as_array()
1602 .ok_or(KimiParameterSchemaError::InvalidKeywordValue)?;
1603 let properties = obj
1604 .get("properties")
1605 .and_then(Value::as_object)
1606 .ok_or(KimiParameterSchemaError::InvalidKeywordValue)?;
1607 let mut seen = HashSet::new();
1608 for name in required {
1609 let name = name
1610 .as_str()
1611 .ok_or(KimiParameterSchemaError::InvalidKeywordValue)?;
1612 if name.is_empty() || !properties.contains_key(name) || !seen.insert(name) {
1613 return Err(KimiParameterSchemaError::InvalidKeywordValue);
1614 }
1615 }
1616 }
1617
1618 if let Some(additional) = obj.get("additionalProperties") {
1619 match additional {
1620 Value::Bool(_) => {}
1621 Value::Object(_) => self.validate_schema(
1622 additional,
1623 false,
1624 true,
1625 property_depth,
1626 structural_depth + 1,
1627 )?,
1628 _ => return Err(KimiParameterSchemaError::InvalidKeywordValue),
1629 }
1630 }
1631
1632 if let Some(items) = obj.get("items") {
1633 self.validate_schema(items, false, false, property_depth, structural_depth + 1)?;
1634 }
1635
1636 validate_mfjs_bounds(obj, schema_type)?;
1637 Ok(())
1638 }
1639 }
1640
1641 fn is_mfjs_concrete_type(schema_type: &str) -> bool {
1642 matches!(
1643 schema_type,
1644 "null" | "boolean" | "object" | "array" | "number" | "integer" | "string"
1645 )
1646 }
1647
1648 fn mfjs_keyword_allowed_for_type(keyword: &str, schema_type: &str, is_root: bool) -> bool {
1649 if is_root && matches!(keyword, "$defs" | "$id") {
1650 return true;
1651 }
1652 if matches!(keyword, "type" | "title" | "description") {
1653 return true;
1654 }
1655 match schema_type {
1656 "object" => matches!(keyword, "properties" | "required" | "additionalProperties"),
1657 "array" => matches!(keyword, "items" | "minItems" | "maxItems"),
1658 "string" => matches!(keyword, "enum" | "default" | "minLength" | "maxLength"),
1659 "number" | "integer" => {
1660 matches!(keyword, "enum" | "default" | "minimum" | "maximum")
1661 }
1662 "boolean" | "null" => keyword == "default",
1663 _ => false,
1664 }
1665 }
1666
1667 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1668 enum MfjsLiteralKind {
1669 Integer,
1670 Number,
1671 String,
1672 }
1673
1674 fn mfjs_literal_kind(value: &Value) -> Option<MfjsLiteralKind> {
1675 if value.is_string() {
1676 return Some(MfjsLiteralKind::String);
1677 }
1678 let number = value.as_number()?;
1679 if number.is_i64() || number.is_u64() {
1680 Some(MfjsLiteralKind::Integer)
1681 } else {
1682 Some(MfjsLiteralKind::Number)
1683 }
1684 }
1685
1686 fn validate_mfjs_enum(
1687 value: &Value,
1688 schema_type: &str,
1689 state: &mut MfjsValidationState<'_>,
1690 ) -> Result<(), KimiParameterSchemaError> {
1691 let values = value
1692 .as_array()
1693 .filter(|values| !values.is_empty())
1694 .ok_or(KimiParameterSchemaError::InvalidKeywordValue)?;
1695 state.total_enum_values = state
1696 .total_enum_values
1697 .checked_add(values.len())
1698 .ok_or(KimiParameterSchemaError::ResourceLimitExceeded)?;
1699 if state.total_enum_values > MFJS_MAX_TOTAL_ENUM_VALUES {
1700 return Err(KimiParameterSchemaError::ResourceLimitExceeded);
1701 }
1702
1703 let values_match_type = match schema_type {
1704 "string" => values.iter().all(Value::is_string),
1705 "integer" => values
1706 .iter()
1707 .all(|value| mfjs_integer_value(value).is_some()),
1708 "number" => values.iter().all(Value::is_number),
1709 _ => false,
1710 };
1711 if !values_match_type {
1712 return Err(KimiParameterSchemaError::InvalidKeywordValue);
1713 }
1714
1715 if values.len() > MFJS_ENUM_LENGTH_CHECK_THRESHOLD {
1716 let encoded_length = values.iter().try_fold(0usize, |total, value| {
1717 let literal_length = value
1718 .as_str()
1719 .map(str::len)
1720 .unwrap_or_else(|| value.to_string().len());
1721 total.checked_add(literal_length)
1722 });
1723 if encoded_length.is_none_or(|length| length > MFJS_MAX_ENUM_STRING_LENGTH) {
1724 return Err(KimiParameterSchemaError::ResourceLimitExceeded);
1725 }
1726 }
1727 Ok(())
1728 }
1729
1730 fn validate_mfjs_default(value: &Value, schema_type: &str) -> Result<(), KimiParameterSchemaError> {
1731 let valid = match schema_type {
1732 "boolean" => value.is_boolean(),
1733 "number" => value.is_number(),
1734 "integer" => mfjs_integer_value(value).is_some(),
1735 "string" => value.is_string(),
1736 "null" => value.is_null(),
1737 _ => false,
1738 };
1739 if valid {
1740 Ok(())
1741 } else {
1742 Err(KimiParameterSchemaError::InvalidDefault)
1743 }
1744 }
1745
1746 fn mfjs_integer_value(value: &Value) -> Option<f64> {
1747 let value = value.as_number()?.as_f64()?;
1748 (value.is_finite() && value.fract() == 0.0 && value.abs() <= MFJS_MAX_SAFE_INTEGER)
1749 .then_some(value)
1750 }
1751
1752 fn validate_mfjs_bounds(
1753 obj: &Map<String, Value>,
1754 schema_type: &str,
1755 ) -> Result<(), KimiParameterSchemaError> {
1756 validate_mfjs_unsigned_range(obj, schema_type, "string", "minLength", "maxLength")?;
1757 validate_mfjs_unsigned_range(obj, schema_type, "array", "minItems", "maxItems")?;
1758
1759 let minimum = obj.get("minimum");
1760 let maximum = obj.get("maximum");
1761 if minimum.is_some() || maximum.is_some() {
1762 let parse_bound = |value: &Value| match schema_type {
1763 "integer" => mfjs_integer_value(value),
1764 "number" => value.as_number().and_then(serde_json::Number::as_f64),
1765 _ => None,
1766 };
1767 let minimum = minimum
1768 .map(|value| parse_bound(value).ok_or(KimiParameterSchemaError::InvalidRange))
1769 .transpose()?;
1770 let maximum = maximum
1771 .map(|value| parse_bound(value).ok_or(KimiParameterSchemaError::InvalidRange))
1772 .transpose()?;
1773 if minimum.zip(maximum).is_some_and(|(min, max)| min > max) {
1774 return Err(KimiParameterSchemaError::InvalidRange);
1775 }
1776 }
1777 Ok(())
1778 }
1779
1780 fn validate_mfjs_unsigned_range(
1781 obj: &Map<String, Value>,
1782 schema_type: &str,
1783 expected_type: &str,
1784 minimum_keyword: &str,
1785 maximum_keyword: &str,
1786 ) -> Result<(), KimiParameterSchemaError> {
1787 let minimum = obj.get(minimum_keyword);
1788 let maximum = obj.get(maximum_keyword);
1789 if minimum.is_none() && maximum.is_none() {
1790 return Ok(());
1791 }
1792 if schema_type != expected_type {
1793 return Err(KimiParameterSchemaError::InvalidRange);
1794 }
1795 let minimum = minimum
1796 .map(|value| value.as_u64().ok_or(KimiParameterSchemaError::InvalidRange))
1797 .transpose()?;
1798 let maximum = maximum
1799 .map(|value| value.as_u64().ok_or(KimiParameterSchemaError::InvalidRange))
1800 .transpose()?;
1801 if minimum.zip(maximum).is_some_and(|(min, max)| min > max) {
1802 return Err(KimiParameterSchemaError::InvalidRange);
1803 }
1804 Ok(())
1805 }
1806
1807 fn resolve_mfjs_reference<'a>(document: &'a Value, reference: &str) -> Option<&'a Value> {
1808 if reference == "#" {
1809 return Some(document);
1810 }
1811 let name = reference.strip_prefix("#/$defs/")?;
1812 if name.is_empty() || name.contains('/') {
1813 return None;
1814 }
1815 document
1816 .pointer(&reference[1..])
1817 .filter(|target| target.is_object())
1818 }
1819
1820 fn mfjs_schema_can_terminate(
1821 schema: &Value,
1822 document: &Value,
1823 visiting_refs: &mut HashSet<String>,
1824 structural_depth: usize,
1825 ) -> Result<bool, KimiParameterSchemaError> {
1826 if structural_depth > MFJS_MAX_STRUCTURAL_DEPTH {
1827 return Err(KimiParameterSchemaError::ResourceLimitExceeded);
1828 }
1829 let obj = schema
1830 .as_object()
1831 .ok_or(KimiParameterSchemaError::InvalidSchemaNode)?;
1832
1833 if let Some(reference) = obj.get("$ref").and_then(Value::as_str) {
1834 if !visiting_refs.insert(reference.to_string()) {
1835 return Ok(false);
1836 }
1837 let target = resolve_mfjs_reference(document, reference)
1838 .ok_or(KimiParameterSchemaError::InvalidReference)?;
1839 let terminates =
1840 mfjs_schema_can_terminate(target, document, visiting_refs, structural_depth + 1)?;
1841 visiting_refs.remove(reference);
1842 return Ok(terminates);
1843 }
1844
1845 if let Some(branches) = obj.get("anyOf").and_then(Value::as_array) {
1846 for branch in branches {
1847 let mut branch_refs = visiting_refs.clone();
1848 if mfjs_schema_can_terminate(branch, document, &mut branch_refs, structural_depth + 1)?
1849 {
1850 return Ok(true);
1851 }
1852 }
1853 return Ok(false);
1854 }
1855
1856 match obj.get("type").and_then(Value::as_str) {
1857 Some("object") => {
1858 let Some(required) = obj.get("required").and_then(Value::as_array) else {
1859 return Ok(true);
1860 };
1861 if required.is_empty() {
1862 return Ok(true);
1863 }
1864 let properties = obj
1865 .get("properties")
1866 .and_then(Value::as_object)
1867 .ok_or(KimiParameterSchemaError::InvalidKeywordValue)?;
1868 for name in required {
1869 let property = name
1870 .as_str()
1871 .and_then(|name| properties.get(name))
1872 .ok_or(KimiParameterSchemaError::InvalidKeywordValue)?;
1873 let mut property_refs = visiting_refs.clone();
1874 if !mfjs_schema_can_terminate(
1875 property,
1876 document,
1877 &mut property_refs,
1878 structural_depth + 1,
1879 )? {
1880 return Ok(false);
1881 }
1882 }
1883 Ok(true)
1884 }
1885 Some("array") => {
1886 if obj.get("minItems").and_then(Value::as_u64).unwrap_or(0) == 0 {
1887 return Ok(true);
1888 }
1889 let items = obj
1890 .get("items")
1891 .ok_or(KimiParameterSchemaError::InvalidSchemaNode)?;
1892 mfjs_schema_can_terminate(items, document, visiting_refs, structural_depth + 1)
1893 }
1894 Some(schema_type) if is_mfjs_concrete_type(schema_type) => Ok(true),
1895 _ => Err(KimiParameterSchemaError::MissingType),
1896 }
1897 }
1898
1899 fn validate_mfjs_expanded_depth(
1900 schema: &Value,
1901 document: &Value,
1902 property_depth: usize,
1903 visiting_refs: &mut HashSet<String>,
1904 structural_depth: usize,
1905 ) -> Result<(), KimiParameterSchemaError> {
1906 if property_depth > MFJS_MAX_OBJECT_DEPTH || structural_depth > MFJS_MAX_STRUCTURAL_DEPTH {
1907 return Err(KimiParameterSchemaError::ResourceLimitExceeded);
1908 }
1909 let obj = schema
1910 .as_object()
1911 .ok_or(KimiParameterSchemaError::InvalidSchemaNode)?;
1912
1913 if let Some(reference) = obj.get("$ref").and_then(Value::as_str) {
1914 if !visiting_refs.insert(reference.to_string()) {
1915 return Ok(());
1916 }
1917 let target = resolve_mfjs_reference(document, reference)
1918 .ok_or(KimiParameterSchemaError::InvalidReference)?;
1919 let result = validate_mfjs_expanded_depth(
1920 target,
1921 document,
1922 property_depth,
1923 visiting_refs,
1924 structural_depth + 1,
1925 );
1926 visiting_refs.remove(reference);
1927 return result;
1928 }
1929
1930 if let Some(properties) = obj.get("properties").and_then(Value::as_object) {
1931 for property in properties.values() {
1932 validate_mfjs_expanded_depth(
1933 property,
1934 document,
1935 property_depth + 1,
1936 visiting_refs,
1937 structural_depth + 1,
1938 )?;
1939 }
1940 }
1941 if let Some(items) = obj.get("items") {
1942 validate_mfjs_expanded_depth(
1943 items,
1944 document,
1945 property_depth,
1946 visiting_refs,
1947 structural_depth + 1,
1948 )?;
1949 }
1950 if let Some(additional) = obj
1951 .get("additionalProperties")
1952 .filter(|additional| additional.is_object())
1953 {
1954 validate_mfjs_expanded_depth(
1955 additional,
1956 document,
1957 property_depth,
1958 visiting_refs,
1959 structural_depth + 1,
1960 )?;
1961 }
1962 if let Some(branches) = obj.get("anyOf").and_then(Value::as_array) {
1963 for branch in branches {
1964 validate_mfjs_expanded_depth(
1965 branch,
1966 document,
1967 property_depth,
1968 visiting_refs,
1969 structural_depth + 1,
1970 )?;
1971 }
1972 }
1973 Ok(())
1974 }
1975
1976 #[cfg(test)]
1977 mod kimi_tests {
1978 use super::*;
1979 use crate::tools::apply_patch::ApplyPatchTool;
1980 use crate::tools::spec::ToolSpec;
1981 use serde_json::json;
1982
1983 #[test]
1984 fn kimi_sanitize_pushes_type_into_anyof_items() {
1985 let mut schema = json!({
1986 "type": "object",
1987 "properties": {
1988 "handle": {
1989 "type": "object",
1990 "anyOf": [
1991 {"type": "string"},
1992 {"type": "null"}
1993 ]
1994 }
1995 }
1996 });
1997 sanitize_for_kimi(&mut schema);
1998 let handle = &schema["properties"]["handle"];
1999 assert!(
2000 !handle.as_object().unwrap().contains_key("type"),
2001 "root type should be removed"
2002 );
2003 let any_of = handle["anyOf"].as_array().unwrap();
2004 assert_eq!(any_of[0]["type"], "string");
2005 assert_eq!(any_of[1]["type"], "null");
2006 }
2007
2008 #[test]
2009 fn kimi_sanitize_injects_missing_anyof_item_types() {
2010 let mut schema = json!({
2011 "type": "object",
2012 "anyOf": [
2013 {"properties": {"path": {"type": "string"}}},
2014 {"required": ["url"], "properties": {"url": {"type": "string"}}}
2015 ]
2016 });
2017
2018 sanitize_for_kimi(&mut schema);
2019
2020 assert!(
2021 !schema.as_object().unwrap().contains_key("type"),
2022 "parent type should be removed"
2023 );
2024 let any_of = schema["anyOf"].as_array().unwrap();
2025 assert_eq!(any_of[0]["type"], "object");
2026 assert_eq!(any_of[1]["type"], "object");
2027 }
2028
2029 #[test]
2030 fn kimi_sanitize_preserves_type_injected_into_nested_anyof_item() {
2031 let mut schema = json!({
2032 "type": "object",
2033 "anyOf": [
2034 {
2035 "anyOf": [
2036 {"properties": {"path": {"type": "string"}}}
2037 ]
2038 }
2039 ]
2040 });
2041
2042 sanitize_for_kimi(&mut schema);
2043
2044 let outer_item = &schema["anyOf"][0];
2045 assert_eq!(outer_item["type"], "object");
2046 assert!(
2047 !schema.as_object().unwrap().contains_key("type"),
2048 "outer parent type should be removed"
2049 );
2050 }
2051
2052 #[test]
2053 fn kimi_sanitize_leaves_pure_object_untouched() {
2054 let original = json!({
2055 "type": "object",
2056 "properties": {"x": {"type": "string"}},
2057 "required": ["x"]
2058 });
2059 let mut schema = original.clone();
2060 sanitize_for_kimi(&mut schema);
2061 assert_eq!(schema, original);
2062 }
2063
2064 #[test]
2065 fn kimi_parameters_add_type_to_empty_root() {
2066 let mut schema = json!({});
2067 sanitize_for_kimi_parameters(&mut schema).unwrap();
2068 assert_eq!(schema, json!({"type": "object", "properties": {}}));
2069 }
2070
2071 #[test]
2072 fn kimi_parameters_add_type_to_properties_root_without_corrupting_properties_map() {
2073 let mut schema = json!({
2074 "properties": {
2075 "path": {"type": "string"}
2076 },
2077 "required": ["path"]
2078 });
2079
2080 sanitize_for_kimi_parameters(&mut schema).unwrap();
2081
2082 assert_eq!(schema["type"], "object");
2083 assert_eq!(schema["properties"]["path"]["type"], "string");
2084 assert!(schema["properties"].get("type").is_none());
2085 }
2086
2087 // Function parameters must end as a plain object root. Composition stays
2088 // available only in valid nested anyOf positions.
2089
2090 #[test]
2091 fn kimi_parameters_add_type_to_anyof_root() {
2092 let mut schema = json!({
2093 "anyOf": [
2094 {"type": "object", "properties": {"path": {"type": "string"}}},
2095 {"type": "null"}
2096 ]
2097 });
2098 sanitize_for_kimi_parameters(&mut schema).unwrap();
2099 assert_eq!(schema["type"], "object");
2100 assert!(schema.get("anyOf").is_none());
2101 assert_eq!(schema["properties"]["path"]["type"], "string");
2102 }
2103
2104 #[test]
2105 fn kimi_parameters_add_type_to_allof_root() {
2106 let mut schema = json!({
2107 "allOf": [
2108 {"type": "object", "properties": {"name": {"type": "string"}}}
2109 ]
2110 });
2111 sanitize_for_kimi_parameters(&mut schema).unwrap();
2112 assert_eq!(schema["type"], "object");
2113 assert!(schema.get("allOf").is_none());
2114 assert_eq!(schema["properties"]["name"]["type"], "string");
2115 }
2116
2117 #[test]
2118 fn kimi_parameters_add_type_to_oneof_root() {
2119 let mut schema = json!({
2120 "oneOf": [
2121 {"type": "object", "properties": {"id": {"type": "integer"}}},
2122 {"type": "object", "properties": {"name": {"type": "string"}}}
2123 ]
2124 });
2125 sanitize_for_kimi_parameters(&mut schema).unwrap();
2126 assert_eq!(schema["type"], "object");
2127 assert!(schema.get("oneOf").is_none());
2128 assert_eq!(schema["properties"]["id"]["type"], "integer");
2129 assert_eq!(schema["properties"]["name"]["type"], "string");
2130 }
2131
2132 #[test]
2133 fn kimi_parameters_flattens_actual_apply_patch_root_and_returns_constraint_note() {
2134 let mut schema = ApplyPatchTool.input_schema();
2135
2136 let note = sanitize_for_kimi_parameters(&mut schema).unwrap();
2137
2138 assert_eq!(schema["type"], "object");
2139 assert!(schema.get("oneOf").is_none());
2140 assert!(schema.get("anyOf").is_none());
2141 assert!(schema.get("allOf").is_none());
2142 assert_eq!(schema["properties"]["patch"]["type"], "string");
2143 assert_eq!(schema["properties"]["replace"]["type"], "array");
2144 assert_eq!(schema["properties"]["changes"]["type"], "array");
2145 assert_eq!(
2146 note.as_deref(),
2147 Some(
2148 "Exactly one of these parameter groups must be provided: `changes` | `patch` | `replace`."
2149 )
2150 );
2151 }
2152
2153 #[test]
2154 fn kimi_parameters_preserves_nested_anyof_branches() {
2155 let mut schema = json!({
2156 "type": "object",
2157 "properties": {
2158 "selector": {
2159 "type": "object",
2160 "anyOf": [
2161 {"properties": {"path": {"type": "string"}}},
2162 {"properties": {"id": {"type": "integer"}}}
2163 ]
2164 }
2165 }
2166 });
2167
2168 sanitize_for_kimi_parameters(&mut schema).unwrap();
2169
2170 assert_eq!(schema["type"], "object");
2171 let selector = &schema["properties"]["selector"];
2172 assert!(selector.get("type").is_none());
2173 let branches = selector["anyOf"].as_array().unwrap();
2174 assert_eq!(branches.len(), 2);
2175 assert!(branches.iter().all(|branch| branch["type"] == "object"));
2176 }
2177
2178 #[test]
2179 fn kimi_parameters_converts_nested_oneof_to_supported_anyof() {
2180 let mut schema = json!({
2181 "type": "object",
2182 "properties": {
2183 "selector": {
2184 "type": "object",
2185 "oneOf": [
2186 {"properties": {"path": {"type": "string"}}},
2187 {"properties": {"id": {"type": "integer"}}}
2188 ]
2189 }
2190 }
2191 });
2192
2193 sanitize_for_kimi_parameters(&mut schema).unwrap();
2194
2195 let selector = &schema["properties"]["selector"];
2196 assert!(selector.get("oneOf").is_none());
2197 assert!(selector["anyOf"].is_array());
2198 assert!(selector.get("type").is_none());
2199 }
2200
2201 #[test]
2202 fn kimi_parameters_restores_registry_collapsed_nullable_anyof() {
2203 let mut schema = json!({
2204 "type": "object",
2205 "properties": {
2206 "query": {
2207 "anyOf": [
2208 {"type": "string"},
2209 {"type": "null"}
2210 ]
2211 }
2212 }
2213 });
2214
2215 // Exercise the exact two-stage production path: ToolRegistry applies
2216 // the provider-neutral pass before the Moonshot request adapter sees
2217 // the schema.
2218 sanitize(&mut schema);
2219 assert_eq!(schema["properties"]["query"]["nullable"], true);
2220 assert!(schema["properties"]["query"].get("anyOf").is_none());
2221
2222 sanitize_for_kimi_parameters(&mut schema).unwrap();
2223
2224 let query = &schema["properties"]["query"];
2225 assert!(query.get("nullable").is_none(), "{query}");
2226 assert_eq!(
2227 query["anyOf"],
2228 json!([{"type": "string"}, {"type": "null"}])
2229 );
2230 validate_mfjs_parameters(&schema).unwrap();
2231 }
2232
2233 #[test]
2234 fn kimi_parameters_recursively_translates_safe_const_to_enum() {
2235 let mut schema = json!({
2236 "type": "object",
2237 "properties": {
2238 "envelope": {
2239 "type": "object",
2240 "properties": {
2241 "items": {
2242 "type": "array",
2243 "items": {
2244 "type": "object",
2245 "properties": {
2246 "kind": {"type": "string", "const": "var_handle"}
2247 },
2248 "required": ["kind"]
2249 }
2250 }
2251 }
2252 }
2253 }
2254 });
2255
2256 sanitize_for_kimi_parameters(&mut schema).unwrap();
2257
2258 let kind = schema
2259 .pointer("/properties/envelope/properties/items/items/properties/kind")
2260 .expect("nested kind schema");
2261 assert!(kind.get("const").is_none(), "{kind}");
2262 assert_eq!(kind["enum"], json!(["var_handle"]));
2263 }
2264
2265 #[test]
2266 fn kimi_parameters_rejects_unsafe_const_without_mutating_or_leaking() {
2267 let mut schema = json!({
2268 "type": "object",
2269 "properties": {
2270 "private-toggle-8172": {"type": "boolean", "const": true}
2271 }
2272 });
2273 let original = schema.clone();
2274
2275 let error = sanitize_for_kimi_parameters(&mut schema).unwrap_err();
2276
2277 assert_eq!(error, KimiParameterSchemaError::UnsupportedConstLiteral);
2278 assert!(!error.to_string().contains("private-toggle-8172"));
2279 assert_eq!(schema, original, "a rejected schema must remain reusable");
2280 }
2281
2282 #[test]
2283 fn kimi_parameters_validator_fails_closed_without_echoing_schema_values() {
2284 let mut schema = json!({
2285 "type": "object",
2286 "properties": {
2287 "private-field-4921": {
2288 "type": "string",
2289 "pattern": "private-pattern-value-7395"
2290 }
2291 }
2292 });
2293 let original = schema.clone();
2294
2295 let error = sanitize_for_kimi_parameters(&mut schema).unwrap_err();
2296 let diagnostic = error.to_string();
2297
2298 assert_eq!(error, KimiParameterSchemaError::UnsupportedKeyword);
2299 assert!(!diagnostic.contains("private-field-4921"));
2300 assert!(!diagnostic.contains("private-pattern-value-7395"));
2301 assert_eq!(schema, original, "failed validation must be transactional");
2302 }
2303
2304 #[test]
2305 fn kimi_parameters_rejects_untyped_schema_and_nullable_transactionally() {
2306 for (mut schema, expected, sentinels) in [
2307 (
2308 json!({
2309 "type": "object",
2310 "properties": {
2311 "private-missing-type-1207": {
2312 "description": "private-description-1208"
2313 }
2314 }
2315 }),
2316 KimiParameterSchemaError::MissingType,
2317 ["private-missing-type-1207", "private-description-1208"],
2318 ),
2319 (
2320 json!({
2321 "type": "object",
2322 "properties": {
2323 "private-nullable-1209": {
2324 "nullable": true,
2325 "description": "private-nullable-description-1210"
2326 }
2327 }
2328 }),
2329 KimiParameterSchemaError::InvalidNullable,
2330 ["private-nullable-1209", "private-nullable-description-1210"],
2331 ),
2332 ] {
2333 let original = schema.clone();
2334 let error = sanitize_for_kimi_parameters(&mut schema).unwrap_err();
2335 assert_eq!(error, expected);
2336 for sentinel in sentinels {
2337 assert!(!error.to_string().contains(sentinel));
2338 }
2339 assert_eq!(schema, original, "rejection must be transactional");
2340 }
2341 }
2342
2343 #[test]
2344 fn kimi_parameters_infers_safe_types_for_untyped_const() {
2345 let mut schema = json!({
2346 "type": "object",
2347 "properties": {
2348 "kind": {"const": "var_handle"},
2349 "count": {"const": 7},
2350 "ratio": {"const": 1.25}
2351 }
2352 });
2353
2354 sanitize_for_kimi_parameters(&mut schema).unwrap();
2355
2356 assert_eq!(schema["properties"]["kind"]["type"], "string");
2357 assert_eq!(schema["properties"]["kind"]["enum"], json!(["var_handle"]));
2358 assert_eq!(schema["properties"]["count"]["type"], "integer");
2359 assert_eq!(schema["properties"]["count"]["enum"], json!([7]));
2360 assert_eq!(schema["properties"]["ratio"]["type"], "number");
2361 assert_eq!(schema["properties"]["ratio"]["enum"], json!([1.25]));
2362 }
2363
2364 #[test]
2365 fn kimi_parameters_allows_only_the_documented_empty_schema_exception() {
2366 let mut schema = json!({
2367 "type": "object",
2368 "additionalProperties": {}
2369 });
2370 sanitize_for_kimi_parameters(&mut schema).unwrap();
2371 assert_eq!(schema["additionalProperties"], json!({}));
2372
2373 let mut invalid = json!({
2374 "type": "object",
2375 "properties": {"value": {}}
2376 });
2377 assert_eq!(
2378 sanitize_for_kimi_parameters(&mut invalid).unwrap_err(),
2379 KimiParameterSchemaError::MissingType
2380 );
2381 }
2382
2383 #[test]
2384 fn kimi_parameters_rejects_required_direct_and_mutual_recursion() {
2385 let fixtures = [
2386 json!({
2387 "type": "object",
2388 "properties": {
2389 "private-root-node-2201": {"$ref": "#/$defs/private-node-2202"}
2390 },
2391 "required": ["private-root-node-2201"],
2392 "$defs": {
2393 "private-node-2202": {
2394 "type": "object",
2395 "properties": {
2396 "private-next-2203": {"$ref": "#/$defs/private-node-2202"}
2397 },
2398 "required": ["private-next-2203"]
2399 }
2400 }
2401 }),
2402 json!({
2403 "type": "object",
2404 "properties": {
2405 "private-root-a-2204": {"$ref": "#/$defs/private-a-2205"}
2406 },
2407 "required": ["private-root-a-2204"],
2408 "$defs": {
2409 "private-a-2205": {
2410 "type": "object",
2411 "properties": {
2412 "private-to-b-2206": {"$ref": "#/$defs/private-b-2207"}
2413 },
2414 "required": ["private-to-b-2206"]
2415 },
2416 "private-b-2207": {
2417 "type": "object",
2418 "properties": {
2419 "private-to-a-2208": {"$ref": "#/$defs/private-a-2205"}
2420 },
2421 "required": ["private-to-a-2208"]
2422 }
2423 }
2424 }),
2425 ];
2426
2427 for mut schema in fixtures {
2428 let original = schema.clone();
2429 let error = sanitize_for_kimi_parameters(&mut schema).unwrap_err();
2430 assert_eq!(error, KimiParameterSchemaError::NonTerminatingReference);
2431 for sentinel in ["private-root", "private-node", "private-to"] {
2432 assert!(!error.to_string().contains(sentinel));
2433 }
2434 assert_eq!(schema, original, "recursive rejection must be atomic");
2435 }
2436 }
2437
2438 #[test]
2439 fn kimi_parameters_preserves_optional_and_nullable_recursive_termination() {
2440 let mut optional = json!({
2441 "type": "object",
2442 "properties": {
2443 "node": {"$ref": "#/$defs/Node"}
2444 },
2445 "$defs": {
2446 "Node": {
2447 "type": "object",
2448 "properties": {
2449 "next": {"$ref": "#/$defs/Node"}
2450 },
2451 "required": ["next"]
2452 }
2453 }
2454 });
2455 sanitize_for_kimi_parameters(&mut optional).unwrap();
2456
2457 let mut nullable = json!({
2458 "type": "object",
2459 "properties": {
2460 "node": {
2461 "anyOf": [
2462 {"$ref": "#/$defs/Node"},
2463 {"type": "null"}
2464 ]
2465 }
2466 },
2467 "required": ["node"],
2468 "$defs": {
2469 "Node": {
2470 "type": "object",
2471 "properties": {
2472 "next": {
2473 "anyOf": [
2474 {"$ref": "#/$defs/Node"},
2475 {"type": "null"}
2476 ]
2477 }
2478 },
2479 "required": ["next"]
2480 }
2481 }
2482 });
2483 sanitize_for_kimi_parameters(&mut nullable).unwrap();
2484 }
2485
2486 #[test]
2487 fn kimi_parameters_enforces_anyof_and_aggregate_resource_limits() {
2488 let mut too_many_branches = json!({
2489 "type": "object",
2490 "properties": {
2491 "choice": {
2492 "anyOf": (0..11)
2493 .map(|_| json!({"type": "string"}))
2494 .collect::<Vec<_>>()
2495 }
2496 }
2497 });
2498 assert_eq!(
2499 sanitize_for_kimi_parameters(&mut too_many_branches).unwrap_err(),
2500 KimiParameterSchemaError::ResourceLimitExceeded
2501 );
2502
2503 let string_property = || json!({"type": "string"});
2504 let mut root_properties = Map::new();
2505 for index in 0..51 {
2506 root_properties.insert(format!("root_{index}"), string_property());
2507 }
2508 let mut definition_properties = Map::new();
2509 for index in 0..50 {
2510 definition_properties.insert(format!("definition_{index}"), string_property());
2511 }
2512 let mut too_many_properties = json!({
2513 "type": "object",
2514 "properties": Value::Object(root_properties),
2515 "$defs": {
2516 "Holder": {
2517 "type": "object",
2518 "properties": Value::Object(definition_properties)
2519 }
2520 }
2521 });
2522 assert_eq!(
2523 sanitize_for_kimi_parameters(&mut too_many_properties).unwrap_err(),
2524 KimiParameterSchemaError::ResourceLimitExceeded
2525 );
2526
2527 let enum_values = |start: usize, count: usize| {
2528 Value::Array((start..start + count).map(|value| json!(value)).collect())
2529 };
2530 let mut too_many_enum_values = json!({
2531 "type": "object",
2532 "properties": {
2533 "first": {"type": "integer", "enum": enum_values(0, 250)},
2534 "second": {"type": "integer", "enum": enum_values(250, 251)}
2535 }
2536 });
2537 assert_eq!(
2538 sanitize_for_kimi_parameters(&mut too_many_enum_values).unwrap_err(),
2539 KimiParameterSchemaError::ResourceLimitExceeded
2540 );
2541 }
2542
2543 #[test]
2544 fn kimi_parameters_enforces_depth_and_enum_text_limits() {
2545 let mut too_deep = json!({"type": "string"});
2546 for index in (0..6).rev() {
2547 let mut properties = Map::new();
2548 properties.insert(format!("level_{index}"), too_deep);
2549 too_deep = json!({
2550 "type": "object",
2551 "properties": Value::Object(properties)
2552 });
2553 }
2554 assert_eq!(
2555 sanitize_for_kimi_parameters(&mut too_deep).unwrap_err(),
2556 KimiParameterSchemaError::ResourceLimitExceeded
2557 );
2558
2559 let long_values = Value::Array(
2560 (0..251)
2561 .map(|index| Value::String(format!("private-enum-{index:04}-xxxxxxxxxxxxxx")))
2562 .collect(),
2563 );
2564 let mut too_much_enum_text = json!({
2565 "type": "object",
2566 "properties": {
2567 "choice": {"type": "string", "enum": long_values}
2568 }
2569 });
2570 assert_eq!(
2571 sanitize_for_kimi_parameters(&mut too_much_enum_text).unwrap_err(),
2572 KimiParameterSchemaError::ResourceLimitExceeded
2573 );
2574 }
2575
2576 #[test]
2577 fn kimi_parameters_validates_default_type_and_placement_without_leaks() {
2578 let mut valid = json!({
2579 "type": "object",
2580 "properties": {
2581 "enabled": {"type": "boolean", "default": true},
2582 "count": {"type": "integer", "default": 3},
2583 "ratio": {"type": "number", "default": 1.5},
2584 "label": {"type": "string", "default": "default-label"},
2585 "empty": {"type": "null", "default": null}
2586 }
2587 });
2588 sanitize_for_kimi_parameters(&mut valid).unwrap();
2589
2590 for (mut invalid, expected) in [
2591 (
2592 json!({
2593 "type": "object",
2594 "properties": {
2595 "private-default-3301": {
2596 "type": "integer",
2597 "default": "private-default-value-3302"
2598 }
2599 }
2600 }),
2601 KimiParameterSchemaError::InvalidDefault,
2602 ),
2603 (
2604 json!({
2605 "type": "object",
2606 "properties": {
2607 "private-untyped-default-3303": {"default": 1}
2608 }
2609 }),
2610 KimiParameterSchemaError::MissingType,
2611 ),
2612 (
2613 json!({
2614 "type": "object",
2615 "properties": {
2616 "private-object-default-3304": {
2617 "type": "object",
2618 "default": {}
2619 }
2620 }
2621 }),
2622 KimiParameterSchemaError::InvalidKeywordValue,
2623 ),
2624 ] {
2625 let original = invalid.clone();
2626 let error = sanitize_for_kimi_parameters(&mut invalid).unwrap_err();
2627 assert_eq!(error, expected);
2628 assert!(!error.to_string().contains("private-default"));
2629 assert_eq!(invalid, original);
2630 }
2631 }
2632
2633 #[test]
2634 fn kimi_parameters_rejects_inverted_and_fractional_integer_bounds() {
2635 for mut schema in [
2636 json!({
2637 "type": "object",
2638 "properties": {
2639 "value": {"type": "string", "minLength": 5, "maxLength": 4}
2640 }
2641 }),
2642 json!({
2643 "type": "object",
2644 "properties": {
2645 "value": {"type": "array", "minItems": 3, "maxItems": 2}
2646 }
2647 }),
2648 json!({
2649 "type": "object",
2650 "properties": {
2651 "value": {"type": "number", "minimum": 10, "maximum": 9}
2652 }
2653 }),
2654 json!({
2655 "type": "object",
2656 "properties": {
2657 "value": {"type": "integer", "minimum": 1.5}
2658 }
2659 }),
2660 json!({
2661 "type": "object",
2662 "properties": {
2663 "value": {"type": "integer", "maximum": 2.5}
2664 }
2665 }),
2666 ] {
2667 assert_eq!(
2668 sanitize_for_kimi_parameters(&mut schema).unwrap_err(),
2669 KimiParameterSchemaError::InvalidRange
2670 );
2671 }
2672 }
2673
2674 #[test]
2675 fn kimi_parameters_inlines_valid_internal_object_root_ref() {
2676 let mut schema = json!({
2677 "$ref": "#/$defs/FileArgs",
2678 "$defs": {
2679 "FileArgs": {
2680 "type": "object",
2681 "properties": {"path": {"type": "string"}},
2682 "required": ["path"]
2683 }
2684 },
2685 "description": "File arguments"
2686 });
2687
2688 sanitize_for_kimi_parameters(&mut schema).unwrap();
2689
2690 assert_eq!(schema["type"], "object");
2691 assert_eq!(schema["properties"]["path"]["type"], "string");
2692 assert_eq!(schema["required"], json!(["path"]));
2693 assert_eq!(schema["description"], "File arguments");
2694 assert!(schema["$defs"].is_object());
2695 assert!(schema.get("$ref").is_none());
2696 assert!(schema.get("allOf").is_none());
2697 }
2698
2699 #[test]
2700 fn kimi_parameters_rejects_unresolved_root_ref_without_leaking_it() {
2701 let mut schema = json!({
2702 "$ref": "#/$defs/private-schema-name-9217",
2703 "$defs": {}
2704 });
2705 let original = schema.clone();
2706
2707 let error = sanitize_for_kimi_parameters(&mut schema).unwrap_err();
2708
2709 assert_eq!(error, KimiParameterSchemaError::UnresolvedRootReference);
2710 assert!(!error.to_string().contains("private-schema-name-9217"));
2711 assert_eq!(schema, original, "a rejected schema must never be emitted");
2712 }
2713
2714 #[test]
2715 fn kimi_parameters_rejects_non_object_root_ref_without_leaking_it() {
2716 let mut schema = json!({
2717 "$ref": "#/$defs/private-scalar-name-4831",
2718 "$defs": {
2719 "private-scalar-name-4831": {"type": "string"}
2720 }
2721 });
2722 let original = schema.clone();
2723
2724 let error = sanitize_for_kimi_parameters(&mut schema).unwrap_err();
2725
2726 assert_eq!(error, KimiParameterSchemaError::ReferencedRootMustBeObject);
2727 assert!(!error.to_string().contains("private-scalar-name-4831"));
2728 assert_eq!(schema, original, "a rejected schema must never be emitted");
2729 }
2730 }
2731
2731 lines RUST