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