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