From 48a2a790f5e5f778f76b53ea57520644f14cbead Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 23:58:43 +0800 Subject: [PATCH 01/20] fix(tui): scope Kimi Code sampling strip to the route, not the roster The Kimi Code membership endpoint answers any sampling value other than the fixed ones with a 400 on every model it serves: a 2026-11-04 live probe (GET /models plus per-model requests) showed all six served ids - k3, k3-256k, k3.1-pro, k3.1-pro-highspeed, kimi-for-coding, kimi-for-coding-highspeed - returning "invalid temperature: only 1 is allowed for this model" / "invalid top_p: only 0.95 is allowed for this model". The strip was gated on the membership roster, so the two k3.1-pro spellings the roster does not list sent user-configured temperature/top_p straight into that 400. This is the roster's third lag behind the endpoint (k3-256k and kimi-for-coding-highspeed before k3.1-pro), so the gate stops consulting it: apply_kimi_code_fixed_sampling now strips temperature/top_p from every request on the exact https://api.kimi.com/coding/v1 route regardless of model id, and its unused model parameter is gone. The direct Moonshot platform strip (api.moonshot.ai + kimi-k3, model-scoped) and the custom-gateway boundary are untouched - the membership contract never leaks to a neighboring base_url, provider identity, or platform endpoint. The roster doc now states that sampling stripping is deliberately not one of its consumers, so nobody re-couples them. Red-green evidence: kimi_code_fixed_sampling_covers_every_model_id_on_ the_route (six probed ids plus an unserved id, through the wire builder), the extended kimi_code_compaction_shape_omits_sampling_parameters_on_wire (roster ids plus both k3.1-pro spellings end to end), and the rewritten kimi_code_fixed_sampling_does_not_leak_to_neighbor_routes (platform, gateway, and non-Moonshot provider boundaries) fail against a temporary reintroduction of the roster gate and pass after its removal; direct_moonshot_k3_uses_modern_token_field_and_fixed_sampling_only_on_ exact_route pins the unchanged platform strip. client:: tools:: gate 2639 green. Signed-off-by: asto --- crates/tui/src/client.rs | 19 ++++- crates/tui/src/client/chat.rs | 133 +++++++++++++++++++++++++++------- crates/tui/src/config.rs | 8 ++ 3 files changed, 129 insertions(+), 31 deletions(-) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 5051141363..902591077d 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -6105,9 +6105,10 @@ mod tests { membership.get("max_completion_tokens").is_none(), "{membership}" ); - // Kimi Code's documented membership models own their sampling - // behavior: the exact first-party membership route strips generic - // controls (apply_kimi_code_fixed_sampling). + // Kimi Code's membership endpoint owns the sampling behavior of + // every model it serves: the exact first-party route strips + // generic controls regardless of model id + // (apply_kimi_code_fixed_sampling). assert!(membership.get("temperature").is_none(), "{membership}"); assert!(membership.get("top_p").is_none(), "{membership}"); } @@ -6459,7 +6460,17 @@ mod tests { #[tokio::test] async fn kimi_code_compaction_shape_omits_sampling_parameters_on_wire() { - for model in crate::config::KIMI_CODE_MEMBERSHIP_MODELS { + // The strip is route-scoped, not roster-scoped: the 2026-11-04 live + // probe showed every id served by this endpoint (GET /models) rejects + // any sampling value other than temperature=1 / top_p=0.95 with a + // 400, so the two `k3.1-pro` spellings the roster does not list yet + // must be stripped the same way. + let models: Vec<&str> = crate::config::KIMI_CODE_MEMBERSHIP_MODELS + .iter() + .copied() + .chain(["k3.1-pro", "k3.1-pro-highspeed"]) + .collect(); + for model in models { let mut request = k3_request_fixture(model, None, /*stream*/ false); request.temperature = Some(0.3); request.top_p = Some(0.8); diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 83d3cbee1c..1c522003a5 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -17,9 +17,8 @@ use tokio::time::timeout as tokio_timeout; use crate::config::{ TOGETHER_INKLING_MODEL, is_exact_direct_moonshot_k3_route, is_exact_kimi_code_k3_route, is_exact_xai_grok_4_6_route, is_exact_zai_chat_route, is_exact_zai_forced_thinking_route, - is_exact_zai_tiered_effort_route, is_kimi_code_membership_model, - minimax_m3_route_uses_max_completion_tokens, moonshot_base_url_is_exact_kimi_code, - wire_model_for_provider_route, + is_exact_zai_tiered_effort_route, minimax_m3_route_uses_max_completion_tokens, + moonshot_base_url_is_exact_kimi_code, wire_model_for_provider_route, }; // The bounded response-header wait (`stream_open_timeout`) and its env @@ -940,21 +939,24 @@ fn apply_direct_moonshot_k3_fixed_sampling( } } -/// Kimi Code's documented membership models own their sampling behavior. -/// Strip generic controls only on the exact first-party membership route; -/// custom gateways and unknown model ids retain their own wire contract. +/// Kimi Code's membership endpoint owns the sampling behavior of every model +/// it serves. Strip generic controls from the exact first-party route +/// regardless of model id; custom gateways and the direct platform route +/// keep their own wire contract. +/// +/// The strip is deliberately route-scoped, not roster-scoped: a 2026-11-04 +/// live probe of this endpoint (GET /models plus per-model requests) showed +/// all six served ids — `k3`, `k3-256k`, `k3.1-pro`, `k3.1-pro-highspeed`, +/// `kimi-for-coding`, `kimi-for-coding-highspeed` — reject anything but the +/// fixed values with a 400 ("invalid temperature: only 1 is allowed for this +/// model" / "invalid top_p: only 0.95 is allowed for this model"). The +/// roster has already lagged behind this endpoint three times (`k3-256k`, +/// `kimi-for-coding-highspeed`, then both `k3.1-pro` spellings), so any +/// model id the endpoint serves next is covered without a code change. /// Source: -/// (verified 2026-08-26). -fn apply_kimi_code_fixed_sampling( - body: &mut Value, - provider: ApiProvider, - base_url: &str, - model: &str, -) { - if provider != ApiProvider::Moonshot - || !moonshot_base_url_is_exact_kimi_code(base_url) - || !is_kimi_code_membership_model(model) - { +/// (verified 2026-08-26; probe verified 2026-11-04). +fn apply_kimi_code_fixed_sampling(body: &mut Value, provider: ApiProvider, base_url: &str) { + if provider != ApiProvider::Moonshot || !moonshot_base_url_is_exact_kimi_code(base_url) { return; } if let Some(object) = body.as_object_mut() { @@ -1244,7 +1246,7 @@ pub(crate) fn build_chat_wire_body( request.reasoning_effort.as_deref(), ); apply_direct_moonshot_k3_fixed_sampling(&mut body, provider, base_url, &model); - apply_kimi_code_fixed_sampling(&mut body, provider, base_url, &model); + apply_kimi_code_fixed_sampling(&mut body, provider, base_url); // Bulletproof final sanitizer: walk the wire payload and force // `reasoning_content` onto any assistant message that has tool_calls @@ -6506,7 +6508,6 @@ mod alias_thinking_detection_tests { &mut body, ApiProvider::Moonshot, crate::config::DEFAULT_KIMI_CODE_BASE_URL, - crate::config::KIMI_CODE_K3_256K_MODEL, ); assert_eq!( @@ -6518,29 +6519,107 @@ mod alias_thinking_detection_tests { assert!(body.get("top_p").is_none()); } + #[test] + fn kimi_code_fixed_sampling_covers_every_model_id_on_the_route() { + // 2026-11-04 live probe (GET /models + per-model requests): all six + // ids served by https://api.kimi.com/coding/v1 answer + // "invalid temperature: only 1 is allowed for this model" / + // "invalid top_p: only 0.95 is allowed for this model" with a 400. + // The roster has lagged this endpoint three times already + // (`k3-256k`, `kimi-for-coding-highspeed`, both `k3.1-pro` + // spellings), so the strip must not consult it — an id the endpoint + // serves next is covered without a code change. + for model in [ + "k3", + "k3-256k", + "k3.1-pro", + "k3.1-pro-highspeed", + "kimi-for-coding", + "kimi-for-coding-highspeed", + // An id the endpoint does not serve (yet) sits on the same route + // and inherits the same route contract. + "k3-next-unknown", + ] { + let request = sampling_probe_request(model); + let body = crate::client::chat::build_chat_wire_body( + &request, + ApiProvider::Moonshot, + crate::config::DEFAULT_KIMI_CODE_BASE_URL, + false, + ) + .expect("Kimi Code wire body"); + assert!( + body.body.get("temperature").is_none(), + "{model}: {}", + body.body + ); + assert!(body.body.get("top_p").is_none(), "{model}: {}", body.body); + assert_eq!(body.body["model"], json!(model), "{model}"); + } + } + + fn sampling_probe_request(model: &str) -> crate::models::MessageRequest { + crate::models::MessageRequest { + model: model.to_string(), + messages: vec![crate::models::Message { + role: crate::models::Role::User, + content: vec![crate::models::ContentBlock::Text { + text: "probe sampling".to_string(), + cache_control: None, + }], + }], + max_tokens: 64, + system: None, + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: None, + stream: None, + temperature: Some(0.3), + top_p: Some(0.8), + } + } + #[test] fn kimi_code_fixed_sampling_does_not_leak_to_neighbor_routes() { for (provider, base_url, model) in [ + // The direct platform route keeps its own model-scoped strip + // (only `kimi-k3`), never the membership route's. ( ApiProvider::Moonshot, crate::config::DEFAULT_MOONSHOT_BASE_URL, crate::config::KIMI_CODE_K3_256K_MODEL, ), + // A compatible gateway on a non-first-party base_url keeps its + // own wire contract even when it claims a membership model id. ( - ApiProvider::Openrouter, - crate::config::DEFAULT_KIMI_CODE_BASE_URL, + ApiProvider::Moonshot, + "https://gateway.example/v1", crate::config::KIMI_CODE_K3_256K_MODEL, ), + // A different provider identity never inherits Moonshot shaping, + // even on the exact membership host. ( - ApiProvider::Moonshot, + ApiProvider::Openrouter, crate::config::DEFAULT_KIMI_CODE_BASE_URL, - "k3-256k-preview", + crate::config::KIMI_CODE_K3_256K_MODEL, ), ] { - let mut body = json!({ "temperature": 0.3, "top_p": 0.8 }); - apply_kimi_code_fixed_sampling(&mut body, provider, base_url, model); - assert_eq!(body["temperature"], json!(0.3)); - assert_eq!(body["top_p"], json!(0.8)); + let request = sampling_probe_request(model); + let body = + crate::client::chat::build_chat_wire_body(&request, provider, base_url, false) + .expect("neighbor wire body"); + assert!( + body.body.get("temperature").is_some(), + "{base_url} {model}: generic sampling must survive: {}", + body.body + ); + assert!( + body.body.get("top_p").is_some(), + "{base_url} {model}: generic sampling must survive: {}", + body.body + ); } } diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index d78aea3fc6..6c549bc9a1 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -10162,6 +10162,14 @@ pub(crate) fn minimax_m3_route_uses_max_completion_tokens( /// model picker labels them as plan routes. Those sites previously kept /// independent literal lists and had already drifted (`kimi-for-coding` was /// missing from the picker label), so the roster lives here and nowhere else. +/// +/// Deliberately NOT a member of this roster's consumer list: wire sampling +/// stripping. A 2026-11-04 live probe showed the membership endpoint serves +/// ids beyond these four (`k3.1-pro`, `k3.1-pro-highspeed`) and rejects +/// sampling values on all of them, so +/// `apply_kimi_code_fixed_sampling` is route-scoped and must not consult +/// this roster — that roster has lagged this endpoint three times +/// (`k3-256k`, `kimi-for-coding-highspeed`, both `k3.1-pro` spellings). pub(crate) const KIMI_CODE_MEMBERSHIP_MODELS: [&str; 4] = [ KIMI_CODE_K3_MODEL, KIMI_CODE_K3_256K_MODEL, From 14b7ac194507b6fc42538a4518e0db6d75fe1fe0 Mon Sep 17 00:00:00 2001 From: asto Date: Thu, 5 Nov 2026 00:01:44 +0800 Subject: [PATCH 02/20] docs(tui): annotate Moonshot schema notes with their 2026-11-04 status The Moonshot compatibility passes still carried narratives written when the behavior was different, with no marker distinguishing history from the present: drop_dependent_keywords described the long-gone whole-request build failure in the present tense, the kimi tests section header claimed composition stays available only in nested anyOf positions, and the candidate-pass note said a rejected shape would be 'failing the whole turn'. Keep every historical account - the incident narratives are the record of why each guard exists - and add the current-state sentence to each: the request builder now degrades per tool (drop + projection warning), the keyword whitelist is route-aware (KimiParameterRoute), and a root union of explicit object branches ships since the walle specification listed anyOf as an applicator and the 2026-11-04 live probe of https://api.kimi.com/coding/v1 confirmed it (200, per-branch required honored). No behavior change; 186 schema_sanitize/moonshot/kimi-matching tests green. Signed-off-by: asto --- crates/tui/src/tools/schema_sanitize.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/tools/schema_sanitize.rs b/crates/tui/src/tools/schema_sanitize.rs index bdc0bda33b..01dd16b713 100644 --- a/crates/tui/src/tools/schema_sanitize.rs +++ b/crates/tui/src/tools/schema_sanitize.rs @@ -397,6 +397,14 @@ fn root_composition_constraint_note(obj: &Map) -> Option /// `sanitize_moonshot_chat_tools`, which fails the whole request build, so one /// composed schema would break *every* tool-bearing Moonshot turn rather than /// degrade its own tool. +/// +/// Status note (2026-11-04): that whole-request failure mode no longer +/// exists — the request builder degrades per tool (drop + projection +/// warning), and the keyword whitelist itself is now route-aware +/// ([`KimiParameterRoute`]). These two conditional keywords remain outside +/// both whitelists (the walle MFJS specification has no conditional +/// keywords), so the strip stays the semantics-preserving way to keep such a +/// tool shippable. fn drop_dependent_keywords(schema: &mut Value) -> Option { strip_dependent_keywords(schema).then(|| { "This provider cannot express conditional requirements from the original schema. \ @@ -1258,7 +1266,8 @@ fn sanitize_kimi_parameters_candidate( // apply_patch and retain its dropped required-group contract as a prompt // note for the model. // MFJS has no conditional keywords. The shared compatibility pass drops - // them with a bounded description note instead of failing the whole turn. + // them with a bounded description note instead of sending a shape the + // validator would reject (which would drop the whole tool). let constraint_note = sanitize_for_responses(parameters); // Restore nullable unions collapsed by the registry's provider-neutral @@ -2228,8 +2237,12 @@ mod kimi_tests { assert!(schema["properties"].get("type").is_none()); } - // Function parameters must end as a plain object root. Composition stays - // available only in valid nested anyOf positions. + // Function parameters must end as a plain object root, or — since the + // walle specification listed anyOf as an applicator and the 2026-11-04 + // probe confirmed it live — as a root union of explicit object branches + // (see kimi_parameters_passes_root_anyof_of_object_branches_through). + // Every other composition stays flattened; the tests below pin those + // protective shapes. #[test] fn kimi_parameters_add_type_to_anyof_root() { From f6b7eee4d05b4a8b7d2157178008ea5c0fd56849 Mon Sep 17 00:00:00 2001 From: asto Date: Thu, 5 Nov 2026 00:22:24 +0800 Subject: [PATCH 03/20] test(tui): ship the pptx per-layout union through Moonshot validation The parent repository's 2026-11-04 batch reshaped the shipped make_pptx slides schema (pinvou3-app/resources/mcp-servers/pptx/server.py, TOOL_DEFS) into a per-layout anyOf so constrained tool-call decoding fills per-branch required fields, and this fork's Moonshot layer grew the validator that must accept it - but the two sides had never been proven against each other end to end. Export the tool definition verbatim from the parent tree into a JSON fixture (tests/fixtures/pptx_make_pptx_schema.json, extracted from the parsed TOOL_DEFS with a read-only ast script: 10 explicit object branches, 47 properties, single-value layout enums, per-branch required groups, nested agenda/kpi item unions, typed chart series) and pin the seam: shipped_pptx_slides_schema_passes_moonshot_validation_on_both_routes runs sanitize_for_kimi_parameters plus validate_mfjs_parameters on both KimiParameterRoute values and asserts the union survives - slides.items stays an anyOf of 10 branches (never flattened into a merged object), every branch keeps its layout enum and required group, the nested agenda/kpi unions keep both branches, and the plain object root with required ["slides"] is untouched with no degradation note. The fixture budgets sit exactly at the MFJS limits the schema author cites (10/10 branches, 47/100 properties, depth 3/5, 13 enum values), so either side's drift turns the test red: a schema regression past a budget in the parent tree, or a renewed flattening of nested unions in this sanitizer. Green against the current head on the first run - no seam between the two batches. schema_sanitize:: tests 74 green; cargo fmt -p codewhale-tui --check clean. Signed-off-by: asto --- crates/tui/src/tools/schema_sanitize.rs | 121 +++++ .../tests/fixtures/pptx_make_pptx_schema.json | 412 ++++++++++++++++++ 2 files changed, 533 insertions(+) create mode 100644 crates/tui/tests/fixtures/pptx_make_pptx_schema.json diff --git a/crates/tui/src/tools/schema_sanitize.rs b/crates/tui/src/tools/schema_sanitize.rs index 01dd16b713..e6b7703f3e 100644 --- a/crates/tui/src/tools/schema_sanitize.rs +++ b/crates/tui/src/tools/schema_sanitize.rs @@ -3323,4 +3323,125 @@ mod kimi_tests { assert!(!error.to_string().contains("private-scalar-name-4831")); assert_eq!(schema, original, "a rejected schema must never be emitted"); } + + // Cross-repo integration seam (parent-repo batch 2026-11-04): the fixture + // is the shipped `make_pptx` tool definition exported verbatim from the + // parent repository's pptx MCP server + // (pinvou3-app/resources/mcp-servers/pptx/server.py, `TOOL_DEFS`). Its + // slides.items is a per-layout anyOf — exactly one explicit `type: object` + // branch per layout, the MFJS branch budget used to the last slot — with + // per-branch required groups, a nested string-or-object item union + // (agenda/kpi entries), and typed nested arrays (chart series). The shape + // exists so constrained tool-call decoding (Kimi K3) fills the per-branch + // required fields instead of collapsing to layout+title-only pages. This + // test pins the seam between the host-side schema authoring and this + // Moonshot compatibility layer: the shipped schema must normalize, pass + // the MFJS validator, and keep its union structure on both Moonshot + // routes. A schema drift past an MFJS budget turns the normalization red; + // a renewed flattening of nested unions turns the branch assertions red — + // each on exactly the side that regressed. + const SHIPPED_PPTX_TOOL_DEFS: &str = + include_str!("../../tests/fixtures/pptx_make_pptx_schema.json"); + + #[test] + fn shipped_pptx_slides_schema_passes_moonshot_validation_on_both_routes() { + let fixture: Value = serde_json::from_str(SHIPPED_PPTX_TOOL_DEFS) + .expect("the exported pptx tool definitions must stay valid JSON"); + let shipped = Value::Object( + fixture["tools"][0]["inputSchema"] + .as_object() + .expect("fixture must carry the make_pptx inputSchema") + .clone(), + ); + + // Fixture sanity: the shipped shape is the per-layout union. Every + // branch declares an explicit object type, selects its layout through + // a single-value enum, and carries the required group its renderer + // needs. + let branch_contract = |branches: &[Value]| -> Vec<(String, Vec)> { + branches + .iter() + .map(|branch| { + assert_eq!( + branch["type"], "object", + "every shipped branch declares an explicit object type" + ); + let mut layouts: Vec = branch["properties"]["layout"]["enum"] + .as_array() + .expect("branch layout enum") + .iter() + .map(|value| value.as_str().expect("string layout").to_string()) + .collect(); + assert_eq!( + layouts.len(), + 1, + "selecting a branch is selecting a layout: single-value enum" + ); + let required: Vec = branch["required"] + .as_array() + .expect("per-branch required group") + .iter() + .map(|value| value.as_str().expect("string name").to_string()) + .collect(); + (layouts.remove(0), required) + }) + .collect() + }; + let shipped_branches = shipped["properties"]["slides"]["items"]["anyOf"] + .as_array() + .expect("slides.items must carry the per-layout anyOf"); + assert_eq!( + shipped_branches.len(), + 10, + "the shipped union has exactly one branch per layout" + ); + let expected = branch_contract(shipped_branches); + + for route in [KimiParameterRoute::Platform, KimiParameterRoute::KimiCode] { + let mut parameters = shipped.clone(); + let note = sanitize_for_kimi_parameters(&mut parameters, route) + .expect("the shipped make_pptx schema must pass Moonshot normalization"); + assert_eq!( + note, None, + "the shipped schema drops no composition, so it must stay unannotated" + ); + validate_mfjs_parameters(¶meters, route) + .expect("the normalized schema must satisfy the MFJS validator"); + + // The root stays the plain object schema the tool declared. + assert_eq!(parameters["type"], "object"); + assert_eq!(parameters["required"], json!(["slides"])); + + // The union survives intact: no flatten into a merged object, + // same branch count, same per-branch layout/required contract. + let items = ¶meters["properties"]["slides"]["items"]; + assert!( + items.get("properties").is_none(), + "slides.items must not be flattened into a merged object: {items}" + ); + let branches = items["anyOf"] + .as_array() + .expect("the slides.items union must survive normalization"); + assert_eq!( + branches.len(), + 10, + "normalization must not change the branch count" + ); + assert_eq!(branch_contract(branches), expected); + + // The nested item union (agenda/kpi entries) survives too. + for layout in ["agenda", "kpi"] { + let branch = branches + .iter() + .find(|branch| branch["properties"]["layout"]["enum"][0] == json!(layout)) + .expect("layout branch"); + let nested = &branch["properties"]["items"]["items"]["anyOf"]; + assert_eq!( + nested.as_array().map(Vec::len), + Some(2), + "the {layout} item union must keep both branches: {nested}" + ); + } + } + } } diff --git a/crates/tui/tests/fixtures/pptx_make_pptx_schema.json b/crates/tui/tests/fixtures/pptx_make_pptx_schema.json new file mode 100644 index 0000000000..7f3fc134db --- /dev/null +++ b/crates/tui/tests/fixtures/pptx_make_pptx_schema.json @@ -0,0 +1,412 @@ +{ + "tools": [ + { + "name": "make_pptx", + "description": "本地直出可编辑 PowerPoint(.pptx):输入结构化 deck(主题 + slides 数组),套主题模板渲染生成,真·可编辑图表,数据不出机。生成后拿到 path,需再调 present_artifact 上卡。", + "inputSchema": { + "type": "object", + "properties": { + "theme": { + "type": "string", + "description": "主题(按 PPT 内容自动选):business-blue/tech-dark/gov-red/creative-purple/fresh-green/warm-orange/navy-gold/minimal-mono/midnight。缺省 business-blue" + }, + "slides": { + "type": "array", + "description": "deck 的 slides 数组。每页一个对象,`layout` 选版式,并**必须按版式填正文字段**(只给 title 会得到空白页!)。每版式的必填字段已写进对应分支的 required:\n- cover: {title}(subtitle/date 可选)\n- agenda: {title, items} 章节条目数组\n- section: {title} ← 章节分隔页\n- bullets: {title, bullets} 要点数组\n- two_col: {title, left, right}(leftTitle/rightTitle 可选)\n- kpi: {title, items} 最多4个大数字\n- chart: {title, chartType?, categories, series}\n- table: {title, headers, rows}\n- image: {title}(image/bullets/caption 可选)\n- end: {title}(subtitle 可选) ← 结尾页\n正文要有真实内容/数据,不要只放标题。", + "items": { + "description": "一页 = 一个版式分支:layout 的单值 enum 选中分支,各分支 required 列出该版式必填字段", + "anyOf": [ + { + "type": "object", + "description": "封面页:必填 title;subtitle/date 可选", + "properties": { + "layout": { + "type": "string", + "enum": [ + "cover" + ], + "description": "版式:封面页" + }, + "title": { + "type": "string", + "description": "主标题" + }, + "subtitle": { + "type": "string", + "description": "副标题" + }, + "date": { + "type": "string", + "description": "日期" + } + }, + "required": [ + "layout", + "title" + ] + }, + { + "type": "object", + "description": "目录页:必填 items(章节条目数组,最多 6 条)", + "properties": { + "layout": { + "type": "string", + "enum": [ + "agenda" + ], + "description": "版式:目录页" + }, + "title": { + "type": "string", + "description": "页标题" + }, + "items": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "num": { + "type": "string", + "description": "大数字,如 68%" + }, + "label": { + "type": "string", + "description": "指标说明" + } + } + } + ] + }, + "description": "章节条目数组(最多 6 条):字符串,或 {num, label}" + } + }, + "required": [ + "layout", + "title", + "items" + ] + }, + { + "type": "object", + "description": "章节分隔页:只要 layout+title", + "properties": { + "layout": { + "type": "string", + "enum": [ + "section" + ], + "description": "版式:章节分隔页" + }, + "title": { + "type": "string", + "description": "页标题" + } + }, + "required": [ + "layout", + "title" + ] + }, + { + "type": "object", + "description": "要点页:必填 bullets(要点字符串数组)", + "properties": { + "layout": { + "type": "string", + "enum": [ + "bullets" + ], + "description": "版式:要点页" + }, + "title": { + "type": "string", + "description": "页标题" + }, + "bullets": { + "type": "array", + "items": { + "type": "string" + }, + "description": "要点,每条一个字符串" + } + }, + "required": [ + "layout", + "title", + "bullets" + ] + }, + { + "type": "object", + "description": "双栏对比页:必填 left/right 两栏要点;leftTitle/rightTitle 可选", + "properties": { + "layout": { + "type": "string", + "enum": [ + "two_col" + ], + "description": "版式:双栏对比页" + }, + "title": { + "type": "string", + "description": "页标题" + }, + "leftTitle": { + "type": "string", + "description": "左栏标题" + }, + "left": { + "type": "array", + "items": { + "type": "string" + }, + "description": "左栏要点" + }, + "rightTitle": { + "type": "string", + "description": "右栏标题" + }, + "right": { + "type": "array", + "items": { + "type": "string" + }, + "description": "右栏要点" + } + }, + "required": [ + "layout", + "title", + "left", + "right" + ] + }, + { + "type": "object", + "description": "大数字指标页:必填 items(最多 4 个 {num, label})", + "properties": { + "layout": { + "type": "string", + "enum": [ + "kpi" + ], + "description": "版式:大数字指标页" + }, + "title": { + "type": "string", + "description": "页标题" + }, + "items": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "num": { + "type": "string", + "description": "大数字,如 68%" + }, + "label": { + "type": "string", + "description": "指标说明" + } + } + } + ] + }, + "description": "指标数组(最多 4 个):{num, label} 或字符串" + } + }, + "required": [ + "layout", + "title", + "items" + ] + }, + { + "type": "object", + "description": "图表页:必填 categories+series;chartType 缺省 bar", + "properties": { + "layout": { + "type": "string", + "enum": [ + "chart" + ], + "description": "版式:图表页" + }, + "title": { + "type": "string", + "description": "页标题" + }, + "chartType": { + "type": "string", + "enum": [ + "bar", + "line", + "pie" + ], + "description": "图表类型,缺省 bar" + }, + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "description": "类目轴标签" + }, + "series": { + "type": "array", + "description": "数据系列,data 与 categories 等长", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "系列名" + }, + "data": { + "type": "array", + "items": { + "type": "number" + }, + "description": "与 categories 等长的数值" + } + }, + "required": [ + "data" + ] + } + } + }, + "required": [ + "layout", + "title", + "categories", + "series" + ] + }, + { + "type": "object", + "description": "表格页:必填 headers+rows,每行与 headers 等长", + "properties": { + "layout": { + "type": "string", + "enum": [ + "table" + ], + "description": "版式:表格页" + }, + "title": { + "type": "string", + "description": "页标题" + }, + "headers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "表头" + }, + "rows": { + "type": "array", + "description": "行数据,每行是与 headers 等长的单元格数组", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "required": [ + "layout", + "title", + "headers", + "rows" + ] + }, + { + "type": "object", + "description": "图片页:image 缺了显示占位框(不强制编造路径);bullets 作侧栏说明,caption 作图注", + "properties": { + "layout": { + "type": "string", + "enum": [ + "image" + ], + "description": "版式:图片页" + }, + "title": { + "type": "string", + "description": "页标题" + }, + "bullets": { + "type": "array", + "items": { + "type": "string" + }, + "description": "图片旁的说明要点" + }, + "image": { + "type": "string", + "description": "本地图片绝对路径(可选,缺了显示占位框)" + }, + "caption": { + "type": "string", + "description": "图注" + } + }, + "required": [ + "layout", + "title" + ] + }, + { + "type": "object", + "description": "结尾页:必填 title(如「谢谢」);subtitle 可选", + "properties": { + "layout": { + "type": "string", + "enum": [ + "end" + ], + "description": "版式:结尾页" + }, + "title": { + "type": "string", + "description": "页标题" + }, + "subtitle": { + "type": "string", + "description": "副标题" + } + }, + "required": [ + "layout", + "title" + ] + } + ] + } + }, + "filename": { + "type": "string", + "description": "文件名(不含扩展名),缺省 演示文稿" + } + }, + "required": [ + "slides" + ] + } + } + ] +} From b91296cac155de9597b2b25701ef29524e1ebc73 Mon Sep 17 00:00:00 2001 From: asto Date: Thu, 5 Nov 2026 01:51:44 +0800 Subject: [PATCH 04/20] fix(tui): cover the overseas Kimi Code host in the sampling strip Kimi Code's official documentation presents two regional membership endpoints for the same product (codex.html, "Kimi Code API address"): China https://api.kimi.com/coding/v1 and overseas https://api.kimi.ai/coding/v1 (re-verified 2026-11-05). The fixed sampling strip reached the wire through the China-only predicate, so an overseas user whose configured base_url points at the .ai host kept generic temperature/top_p on the wire and hit the exact 400 class this strip exists to prevent ("invalid temperature: only 1 is allowed for this model"). The strip now matches through kimi_code_sampling_route, a route-scoped twin of codewhale_config::provider::is_exact_https_route spanning both hosts with identical semantics: scheme and authority ASCII case-insensitive, path exact, a single trailing slash insignificant, neighboring paths and lookalike hosts never match. The config-crate helper is private and widening is_exact_kimi_code_route belongs to in-flight PR #137, so the twin lives in chat.rs and nothing under crates/config changes. The KimiParameterRoute selection deliberately stays on the China-only predicate: the relaxed MFJS keyword tier is probe-backed for .com only (2026-11-04, 6/6 models on the China endpoint), so .ai keeps the conservative platform whitelist until an independent probe or PR #137 folds it in. overseas_kimi_ai_endpoint_keeps_the_conservative_parameter_ whitelist pins that boundary at the wire level - a pattern-carrying tool still degrades per tool on .ai while sampling is stripped - and sanitize_moonshot_chat_tools' doc no longer claims the two predicates are the same. The extrapolation's failure mode is benign: not sending temperature/top_p is the officially documented configuration for this endpoint. Also correct the Platform route documentation, which read as "byte-identical to the f1eb10efc baseline": relative to that baseline the platform route carries two intentional behavior changes - root level object unions pass through (anyOf/oneOf with all-object branches no longer flattened; backed by the walle MFJS specification listing anyOf as a first-class applicator plus the 2026-11-04 probe), and an array with minItems >= 1 and items absent counts as terminating instead of failing the tool (ported from upstream codewhale-hq e3e563204, the #7015 review follow-up). The keyword whitelist itself, nested allOf rejection on the platform route, and per-tool degradation stay baseline-consistent. Docs only; no behavior change in this part. Tests (the two new ones red before the predicate change, green after): - kimi_code_fixed_sampling_covers_the_overseas_api_kimi_ai_endpoint (.ai exact route, trailing slash, uppercase host) - overseas_kimi_ai_endpoint_keeps_the_conservative_parameter_whitelist - kimi_code_fixed_sampling_does_not_leak_to_neighbor_routes extended with the api.kimi.ai/coding/v2 and api.kimi.com/coding/ neighbors; existing neighbor non-leak and every-model-id coverage stay green. Gates: cargo test --locked -p codewhale-tui --lib -- client:: tools:: 2643 green (the forkguard_model_operation_auxiliary_retry_keys full-concurrency flake stays green single-run; the full-lib run still aborts with a SIGABRT stack overflow reproduced identically on clean 13f95e961, so it is pre-existing and unrelated); config:: 508, route_billing 46, kimi/moonshot filters 159 green. cargo fmt --all -- --check clean. Workspace clippy with the CI lint parameters reports only the pre-existing clippy::collapsible_if at crates/tui/src/core/engine/turn_loop.rs:5499 (introduced by 6ba52af35, verified present on clean head) - nothing in the touched files. Signed-off-by: asto --- crates/tui/src/client/chat.rs | 209 ++++++++++++++++++++++-- crates/tui/src/tools/schema_sanitize.rs | 63 +++++++ 2 files changed, 261 insertions(+), 11 deletions(-) diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 1c522003a5..945b6403e6 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -939,24 +939,76 @@ fn apply_direct_moonshot_k3_fixed_sampling( } } -/// Kimi Code's membership endpoint owns the sampling behavior of every model -/// it serves. Strip generic controls from the exact first-party route +/// Whether a configured route is exactly one of Kimi Code's official +/// membership endpoints: China `https://api.kimi.com/coding/v1` or overseas +/// `https://api.kimi.ai/coding/v1`. +/// +/// The official Kimi Code documentation lists both hosts for the same +/// product ("Kimi Code API address — China `https://api.kimi.com/coding/v1`, +/// Overseas `https://api.kimi.ai/coding/v1`", +/// , re-verified +/// 2026-11-05), so the membership route contract spans both spellings. +/// +/// Matching semantics mirror `codewhale_config::provider::is_exact_https_route` +/// (the config-crate matcher behind `is_exact_kimi_code_route`): scheme and +/// authority compare ASCII case-insensitively, the path compares exactly, a +/// single trailing slash is insignificant, and lookalike hosts or neighboring +/// paths never match. That helper is private to the config crate and widening +/// `is_exact_kimi_code_route` itself belongs to in-flight PR #137, so the +/// sampling strip carries this route-scoped twin here; it must stay in sync +/// with those semantics. +/// +/// Deliberately narrower consumers keep the stricter China-only predicate +/// (`moonshot_base_url_is_exact_kimi_code`): the `KimiParameterRoute` +/// whitelist selection is probe-backed only for `.com`, and the replay / +/// billing contracts aligned with it advance through #137. +#[must_use] +fn kimi_code_sampling_route(base_url: &str) -> bool { + const KIMI_CODE_ENDPOINTS: [(&str, &str); 2] = + [("api.kimi.com", "coding/v1"), ("api.kimi.ai", "coding/v1")]; + + let trimmed = base_url.trim(); + let normalized = trimmed.strip_suffix('/').unwrap_or(trimmed); + let Some((scheme, authority_and_path)) = normalized.split_once("://") else { + return false; + }; + let Some((authority, path)) = authority_and_path.split_once('/') else { + return false; + }; + + scheme.eq_ignore_ascii_case("https") + && KIMI_CODE_ENDPOINTS.iter().any(|(host, endpoint_path)| { + authority.eq_ignore_ascii_case(host) && path == *endpoint_path + }) +} + +/// Kimi Code's membership endpoints own the sampling behavior of every model +/// they serve. Strip generic controls from the exact first-party routes /// regardless of model id; custom gateways and the direct platform route /// keep their own wire contract. /// /// The strip is deliberately route-scoped, not roster-scoped: a 2026-11-04 -/// live probe of this endpoint (GET /models plus per-model requests) showed -/// all six served ids — `k3`, `k3-256k`, `k3.1-pro`, `k3.1-pro-highspeed`, -/// `kimi-for-coding`, `kimi-for-coding-highspeed` — reject anything but the -/// fixed values with a 400 ("invalid temperature: only 1 is allowed for this -/// model" / "invalid top_p: only 0.95 is allowed for this model"). The -/// roster has already lagged behind this endpoint three times (`k3-256k`, -/// `kimi-for-coding-highspeed`, then both `k3.1-pro` spellings), so any -/// model id the endpoint serves next is covered without a code change. +/// live probe of the China endpoint (GET /models plus per-model requests) +/// showed all six served ids — `k3`, `k3-256k`, `k3.1-pro`, +/// `k3.1-pro-highspeed`, `kimi-for-coding`, `kimi-for-coding-highspeed` — +/// reject anything but the fixed values with a 400 ("invalid temperature: +/// only 1 is allowed for this model" / "invalid top_p: only 0.95 is allowed +/// for this model"). The roster has already lagged behind this endpoint +/// three times (`k3-256k`, `kimi-for-coding-highspeed`, then both `k3.1-pro` +/// spellings), so any model id the endpoint serves next is covered without a +/// code change. +/// +/// The overseas host `api.kimi.ai` joins on contract extrapolation: the +/// official documentation presents both regional endpoints as the same +/// product with the same wire contract, and the 6/6-model 400 probe covered +/// only the China spelling. The failure mode of the extrapolation is benign — +/// not sending `temperature`/`top_p` is exactly the official recommended +/// configuration for this endpoint, so an `.ai` route that would have +/// tolerated generic sampling merely ships the documented shape instead. /// Source: /// (verified 2026-08-26; probe verified 2026-11-04). fn apply_kimi_code_fixed_sampling(body: &mut Value, provider: ApiProvider, base_url: &str) { - if provider != ApiProvider::Moonshot || !moonshot_base_url_is_exact_kimi_code(base_url) { + if provider != ApiProvider::Moonshot || !kimi_code_sampling_route(base_url) { return; } if let Some(object) = body.as_object_mut() { @@ -1055,6 +1107,14 @@ fn mirror_minimax_reasoning_details_for_body(body: &mut Value, provider: ApiProv /// before transport. The tool name is safe to log — it is already visible in /// the UI — while the error's `Display` deliberately carries no schema values. /// +/// The route selects the keyword whitelist: the exact Kimi Code endpoint +/// (`moonshot_base_url_is_exact_kimi_code`, the China-only predicate) admits +/// the keywords its 2026-11-04 live probe verified, while platform endpoints +/// keep the conservative MFJS whitelist. This predicate is deliberately +/// stricter than the fixed-sampling strip (`kimi_code_sampling_route`), whose +/// overseas `.ai` host has no probe evidence of its own and therefore no +/// whitelist relaxation either. +/// /// Returns the names of the dropped tools, in catalog order. fn sanitize_moonshot_chat_tools(chat_tools: &mut Vec) -> Vec { let mut dropped = Vec::new(); @@ -6605,6 +6665,20 @@ mod alias_thinking_detection_tests { crate::config::DEFAULT_KIMI_CODE_BASE_URL, crate::config::KIMI_CODE_K3_256K_MODEL, ), + // Neighboring paths on the overseas membership host are not the + // coding endpoint; the path must match exactly. + ( + ApiProvider::Moonshot, + "https://api.kimi.ai/coding/v2", + crate::config::KIMI_CODE_K3_256K_MODEL, + ), + // Neighboring paths on the China membership host stay neighbors + // even when they share the `coding` prefix. + ( + ApiProvider::Moonshot, + "https://api.kimi.com/coding/", + crate::config::KIMI_CODE_K3_256K_MODEL, + ), ] { let request = sampling_probe_request(model); let body = @@ -6623,6 +6697,119 @@ mod alias_thinking_detection_tests { } } + #[test] + fn kimi_code_fixed_sampling_covers_the_overseas_api_kimi_ai_endpoint() { + // The official Kimi Code documentation lists two regional endpoints + // for the same membership product: China + // `https://api.kimi.com/coding/v1` and overseas + // `https://api.kimi.ai/coding/v1` (codex.html, "Kimi Code API + // address"). A configured base_url on the overseas host is the same + // route contract, so the strip must follow the route, not one + // spelling of its host. + for base_url in [ + "https://api.kimi.ai/coding/v1", + // A trailing slash is insignificant and scheme/host are ASCII + // case-insensitive — the same semantics the config-crate + // exact-route matcher applies to the `.com` spelling. + "https://api.kimi.ai/coding/v1/", + "https://API.KIMI.AI/coding/v1", + ] { + let request = sampling_probe_request("k3.1-pro"); + let body = crate::client::chat::build_chat_wire_body( + &request, + ApiProvider::Moonshot, + base_url, + false, + ) + .expect("overseas Kimi Code wire body"); + assert!( + body.body.get("temperature").is_none(), + "{base_url}: fixed sampling must strip temperature: {}", + body.body + ); + assert!( + body.body.get("top_p").is_none(), + "{base_url}: fixed sampling must strip top_p: {}", + body.body + ); + assert_eq!(body.body["model"], json!("k3.1-pro"), "{base_url}"); + } + } + + #[test] + fn overseas_kimi_ai_endpoint_keeps_the_conservative_parameter_whitelist() { + // Widening the sampling strip to the overseas host must NOT widen + // the KimiParameterRoute selection: the relaxed whitelist tier is + // backed by a live probe of the China endpoint only, so `pattern` (a + // KimiCode-route keyword) still fails the platform whitelist on + // `.ai` and the tool degrades per tool. Sampling shaping and route + // selection are deliberately split. + let patterned_tool = crate::models::Tool { + tool_type: None, + name: "patterned".to_string(), + description: "Find a label.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "label": {"type": "string", "pattern": "^[a-z]+$"} + }, + "required": ["label"] + }), + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + }; + let mut request = sampling_probe_request("k3.1-pro"); + request.tools = Some(vec![patterned_tool]); + + let overseas = crate::client::chat::build_chat_wire_body( + &request, + ApiProvider::Moonshot, + "https://api.kimi.ai/coding/v1", + false, + ) + .expect("overseas Kimi Code wire body"); + assert_eq!( + overseas.omitted_tool_names, + vec!["patterned".to_string()], + "the conservative platform whitelist must stay in force on `.ai`" + ); + assert!( + overseas.body.get("tools").is_none(), + "the dropped tool must not ship: {}", + overseas.body + ); + // The sampling strip, by contrast, does cover the overseas route. + assert!( + overseas.body.get("temperature").is_none() && overseas.body.get("top_p").is_none(), + "fixed sampling must still strip on `.ai`: {}", + overseas.body + ); + + // The China endpoint keeps the relaxed tier it was probed for: the + // same tool ships with its `pattern` intact. + let china = crate::client::chat::build_chat_wire_body( + &request, + ApiProvider::Moonshot, + crate::config::DEFAULT_KIMI_CODE_BASE_URL, + false, + ) + .expect("China Kimi Code wire body"); + assert!( + china.omitted_tool_names.is_empty(), + "the probe-verified keyword must ship on the probed route: {:?}", + china.omitted_tool_names + ); + assert_eq!( + china.body["tools"][0]["function"]["parameters"]["properties"]["label"]["pattern"], + json!("^[a-z]+$"), + "pattern must survive on the China coding route: {}", + china.body + ); + } + #[test] fn direct_moonshot_k3_uses_top_level_effort_and_never_disables_thinking() { for (requested, expected) in [ diff --git a/crates/tui/src/tools/schema_sanitize.rs b/crates/tui/src/tools/schema_sanitize.rs index e6b7703f3e..0a259c0f43 100644 --- a/crates/tui/src/tools/schema_sanitize.rs +++ b/crates/tui/src/tools/schema_sanitize.rs @@ -1220,6 +1220,69 @@ pub enum KimiParameterSchemaError { InvalidRange, } +/// Which Moonshot wire contract a parameters schema is being normalized for. +/// +/// The MFJS keyword whitelist is route-sensitive: the Kimi Code membership +/// endpoint accepts a set of extra keywords that the platform endpoints have +/// never been probed for, so platform traffic keeps the conservative +/// whitelist while the exact coding route gets the live-verified additions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KimiParameterRoute { + /// Moonshot platform endpoints (`api.moonshot.cn` / `api.moonshot.ai`) and + /// every non-exact Moonshot route: the conservative MFJS keyword + /// whitelist, unchanged. The walle specification does not list the extra + /// keywords for these endpoints and no live probe backs them, so an + /// unsupported keyword there still risks a whole-request 400 — the + /// asymmetry that keeps this route strict. + /// + /// "Conservative whitelist, unchanged" refers to the MFJS keyword + /// allow-list, not to this route's whole normalization behavior. Relative + /// to the pre-2026-11 baseline (`f1eb10efc`) the platform route carries + /// two intentional behavior changes, both shipped for the Kimi Code route + /// first and applied here because they are route-independent in code: + /// + /// 1. Root-level object unions pass through: an `anyOf` (or a promoted + /// `oneOf`) whose every branch declares `type: "object"` is no longer + /// flattened into one merged object, so per-branch `required` groups + /// survive. Backed by the walle MFJS specification listing `anyOf` as + /// a first-class applicator (plus the 2026-11-04 live probe of the + /// coding endpoint). + /// 2. Array termination without `items`: an array required to be + /// non-empty (`minItems >= 1`) with `items` absent now counts as + /// terminating instead of failing with `InvalidSchemaNode`, so the + /// tool ships rather than being excluded. Ported from upstream + /// codewhale-hq `e3e563204` (#7015 review follow-up). + /// + /// Everything else is baseline-consistent: the keyword whitelist itself, + /// nested `allOf` failing closed here (open only on [`KimiParameterRoute::KimiCode`]), + /// and per-tool degradation for schemas that cannot pass validation. + Platform, + /// The exact first-party Kimi Code endpoint + /// (`https://api.kimi.com/coding/v1`): the conservative whitelist plus the + /// keywords a 2026-11-04 live probe verified end to end (200, model + /// honors the constraints): `pattern`, `format`, `$schema`, `prefixItems`, + /// `exclusiveMinimum`, `exclusiveMaximum`, and nested `allOf` (accepted by + /// the server; the documented position is that unstated keywords are + /// simply not guaranteed stable, so their semantics stay the schema + /// author's responsibility). + /// + /// Route selection deliberately keeps matching only the China host + /// (`api.kimi.com`) even though the sampling strip also covers the + /// overseas `api.kimi.ai/coding/v1` spelling: the whitelist relaxation is + /// backed by a probe of the China endpoint only, and `.ai` has no probe + /// evidence of its own, so it stays on [`KimiParameterRoute::Platform`]'s + /// conservative tier until an independent probe or the predicate work in + /// in-flight PR #137 widens `is_exact_kimi_code_route`. + KimiCode, +} + +impl KimiParameterRoute { + /// Whether this route admits the live-verified keyword extensions. + fn is_relaxed(self) -> bool { + matches!(self, KimiParameterRoute::KimiCode) + } +} + /// Normalize a complete Kimi / Moonshot `function.parameters` object. /// /// Function parameters have an additional MFJS constraint: the root must end From ebef9348a4abba6620e3839c91005f32ff7d784f Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 22:30:14 +0800 Subject: [PATCH 05/20] fix(tui): pass Moonshot root unions of object branches through The 2026-06 Kimi incident shape - a parameters root anyOf whose branches carry their own required groups - was flattened into one merged object by sanitize_for_responses, erasing every branch's required set. k3's constrained decoding then only ever emitted the single merged group on long contexts (the real pptx-tool case: the wire schema was left with one required set where the schema author had written two). Moonshot's MFJS has since made this shape legal. The walle specification (https://github.com/MoonshotAI/walle/blob/main/docs/mfjs-spec.zh.md) lists anyOf as a first-class applicator - type must live inside the branches and every branch must be an object schema - and its response_format guide states k3/k3.1-pro reliably support structured output including anyOf. A live probe of https://api.kimi.com/coding/v1 on 2026-11-04 returned 200 for a root anyOf of object branches with per-branch required honored, and accepted the parent-side type of the 2026-06 incident form without error. Narrow the flattening accordingly. sanitize_for_kimi_parameters now passes a root union through when a single anyOf/oneOf carries only explicit type:"object" branches beside legal union siblings (type, title, description, root-only $defs/$id), promoting a root oneOf to the documented anyOf; normalize_kimi_compatibility's type-pushdown then moves a parent-side type into the branches so the sent shape matches the specification. validate_mfjs_parameters accepts the union root while its union-sibling check still rejects a type beside the keyword. Anything else - a union with a non-object branch (apply_patch's bare required-groups among them), both union keywords at once, root allOf/enum/not, or an unrepresented keyword beside the union - keeps the flatten and its constraint note, so no unverified shape ships. Red-green evidence: kimi_parameters_passes_root_anyof_of_object_branches_ through, kimi_parameters_promotes_root_oneof_of_object_branches_to_anyof, kimi_parameters_pushes_root_type_into_object_union_branches, mfjs_ validator_accepts_union_root_but_rejects_union_siblings, and kimi_chat_ keeps_root_union_of_object_branches fail against the old implementation (root union folded, branches erased) and pass after the narrowing; kimi_parameters_add_type_to_anyof_root (null branch), kimi_parameters_add_ type_to_allof_root, kimi_parameters_still_flattens_root_union_with_non_ object_branch, kimi_parameters_still_flattens_root_union_with_extra_root_ keywords, and kimi_parameters_flattens_actual_apply_patch_root_and_returns_ constraint_note pin the preserved flatten. 72 schema_sanitize-matching tests green; client:: tools:: gate 2634 green. Signed-off-by: asto --- crates/tui/src/client/chat.rs | 113 +++++++- crates/tui/src/tools/schema_sanitize.rs | 325 ++++++++++++++++++++++-- 2 files changed, 406 insertions(+), 32 deletions(-) diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 945b6403e6..23afb503b8 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -1240,10 +1240,12 @@ pub(crate) fn build_chat_wire_body( .iter() .map(|tool| tool_to_chat_for_base_url(tool, base_url)) .collect(); - // Moonshot function parameters must end at a plain object root. - // Flatten root composition, preserve valid nested anyOf, and drop - // only the tools whose parameters cannot pass MFJS validation so one - // incompatible tool never sinks the whole request. + // Moonshot function parameters must end at a plain object root or at + // the MFJS-legal root union of object branches (walle specification; + // 2026-11-04 live probe). Flatten remaining root composition, + // preserve valid nested anyOf, and drop only the tools whose + // parameters cannot pass MFJS validation so one incompatible tool + // never sinks the whole request. if matches!(provider, crate::config::ApiProvider::Moonshot) { omitted_tool_names = sanitize_moonshot_chat_tools(&mut chat_tools); } @@ -8605,6 +8607,109 @@ mod image_block_wire_tests { "a non-empty description keeps the appended-note form" ); } + + /// Moonshot's MFJS specification lists `anyOf` as a first-class + /// applicator, and a 2026-11-04 live probe of the Kimi Code endpoint + /// returned 200 for a root union of object branches with per-branch + /// `required` groups honored. The Moonshot lane must keep such a union — + /// with each branch's own required set — instead of folding it into one + /// merged object whose single required group starved k3's constrained + /// decoding into emitting only the required fields. + #[test] + fn kimi_chat_keeps_root_union_of_object_branches() { + let union_schema = serde_json::json!({ + "anyOf": [ + { + "type": "object", + "properties": { + "kind": {"type": "string"}, + "outline": {"type": "string"} + }, + "required": ["kind", "outline"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": {"type": "string"}, + "content": {"type": "string"} + }, + "required": ["kind", "content"], + "additionalProperties": false + } + ] + }); + let tool = Tool { + tool_type: None, + name: "deck".to_string(), + description: "Build a slide deck.".to_string(), + input_schema: union_schema, + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + }; + let request = MessageRequest { + model: crate::config::KIMI_CODE_K3_MODEL.to_string(), + messages: vec![Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "build the deck".to_string(), + cache_control: None, + }], + }], + max_tokens: 128, + system: None, + tools: Some(vec![tool]), + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: None, + stream: None, + temperature: None, + top_p: None, + }; + + let body = build_chat_wire_body( + &request, + ApiProvider::Moonshot, + crate::config::DEFAULT_KIMI_CODE_BASE_URL, + false, + ) + .expect("Kimi Code wire body"); + + assert!( + body.omitted_tool_names.is_empty(), + "the union root must not be dropped: {:?}", + body.omitted_tool_names + ); + let parameters = &body.body["tools"][0]["function"]["parameters"]; + let branches = parameters["anyOf"] + .as_array() + .expect("root anyOf must survive"); + assert_eq!(branches.len(), 2, "both object variants must survive"); + assert_eq!( + branches[0]["required"], + serde_json::json!(["kind", "outline"]) + ); + assert_eq!( + branches[1]["required"], + serde_json::json!(["kind", "content"]) + ); + assert!( + branches.iter().all(|branch| branch["type"] == "object"), + "MFJS requires the object type inside every branch: {parameters}" + ); + assert!( + parameters.get("type").is_none(), + "type may not sit beside the union keyword: {parameters}" + ); + assert!( + parameters.get("properties").is_none(), + "branch properties must not be merged into the root: {parameters}" + ); + } } #[cfg(test)] diff --git a/crates/tui/src/tools/schema_sanitize.rs b/crates/tui/src/tools/schema_sanitize.rs index 0a259c0f43..f8643a57ef 100644 --- a/crates/tui/src/tools/schema_sanitize.rs +++ b/crates/tui/src/tools/schema_sanitize.rs @@ -85,6 +85,55 @@ pub fn sanitize_for_xai_parameters(parameters: &mut Value) -> Option { sanitize_for_responses(parameters) } +fn is_object_branch(branch: &Value) -> bool { + branch.get("type").and_then(Value::as_str) == Some("object") +} + +/// Whether the schema root is a single `anyOf`/`oneOf` union whose every +/// branch declares `type: "object"`, beside only keywords that survive as +/// legal union siblings after the Kimi compatibility passes (`type` is pushed +/// down into the branches; annotations and root-only `$defs`/`$id` are +/// permitted). Returns the union's keyword so a root `oneOf` can be promoted +/// to the MFJS-documented `anyOf`. +/// +/// The Moonshot MFJS specification +/// () +/// admits `anyOf` as a first-class applicator: `type` must live inside the +/// branches, and every branch must be an object schema. A live probe of +/// on 2026-11-04 returned 200 for such root +/// unions with per-branch `required` groups honored, so they no longer need +/// the semantics-losing root flatten (which merged the branches' required +/// sets and left k3's constrained decoding to emit only the single merged +/// group). Anything else — a union with a non-object branch, both union +/// keywords at once, or an unrepresented keyword beside the union — returns +/// `None` and keeps the protective flatten. +fn kimi_root_object_union(schema: &Value) -> Option<(&'static str, &Vec)> { + let obj = schema.as_object()?; + if obj.contains_key("anyOf") && obj.contains_key("oneOf") { + // Two independent unions at the root would have to survive together; + // MFJS documents only `anyOf`, so the flatten keeps the safe merge. + return None; + } + let union_key: &'static str = if obj.contains_key("anyOf") { + "anyOf" + } else if obj.contains_key("oneOf") { + "oneOf" + } else { + return None; + }; + let branches = obj.get(union_key)?.as_array()?; + if branches.is_empty() || branches.iter().any(|branch| !is_object_branch(branch)) { + return None; + } + for key in obj.keys() { + match key.as_str() { + "anyOf" | "oneOf" | "type" | "title" | "description" | "$defs" | "$id" => {} + _ => return None, + } + } + Some((union_key, branches)) +} + /// Sanitize a schema for OpenAI Responses function tools. /// /// The Responses API requires the top-level `parameters` schema to be an object @@ -1286,10 +1335,17 @@ impl KimiParameterRoute { /// Normalize a complete Kimi / Moonshot `function.parameters` object. /// /// Function parameters have an additional MFJS constraint: the root must end -/// as a plain `type: "object"` schema. Root composition is flattened using the +/// as a plain `type: "object"` schema or as the MFJS-legal object union — a +/// single `anyOf` whose every branch declares `type: "object"` (a root +/// `oneOf` of object branches is promoted to `anyOf`). The union shape is +/// backed by the walle MFJS specification, which lists `anyOf` as a +/// first-class applicator, and by a 2026-11-04 live probe of +/// (200, per-branch `required` honored); +/// keeping the branches preserves their per-branch required groups, which the +/// former flatten erased. Every other root composition is flattened using the /// same compatibility pass as Responses and xAI, while supported nested -/// `anyOf` branches remain nested. Internal root `$ref` values are resolved and -/// inlined before normalization so we never manufacture the invalid +/// `anyOf` branches remain nested. Internal root `$ref` values are resolved +/// and inlined before normalization so we never manufacture the invalid /// `type + allOf($ref)` shape rejected by MFJS. /// /// Unsupported, unresolved, cyclic, and non-object root references fail @@ -1324,14 +1380,30 @@ fn sanitize_kimi_parameters_candidate( inline_internal_kimi_root_ref(parameters)?; - // A function schema's root cannot carry MFJS composition because it must - // simultaneously be `type: object`. Flatten the actual root shape used by - // apply_patch and retain its dropped required-group contract as a prompt - // note for the model. + // A root union of explicit object branches is MFJS-legal since the walle + // specification listed `anyOf` as a first-class applicator (live probe + // 2026-11-04: 200 with per-branch `required` honored), so it passes + // through with only semantics-preserving normalization. A root `oneOf` is + // promoted to the documented `anyOf` first. Anything else — a union with + // a non-object branch (apply_patch's bare required-groups among them), + // root allOf/enum/not, or an unrepresented keyword beside the union — + // keeps the flatten, and retains its dropped required-group contract as a + // prompt note for the model. // MFJS has no conditional keywords. The shared compatibility pass drops // them with a bounded description note instead of sending a shape the // validator would reject (which would drop the whole tool). - let constraint_note = sanitize_for_responses(parameters); + let constraint_note = if kimi_root_object_union(parameters).is_some() { + if let Some(root) = parameters.as_object_mut() + && let Some(one_of) = root.remove("oneOf") + { + root.insert("anyOf".to_string(), one_of); + } + let dependent_note = drop_dependent_keywords(parameters); + sanitize(parameters); + dependent_note + } else { + sanitize_for_responses(parameters) + }; // Restore nullable unions collapsed by the registry's provider-neutral // sanitizer, translate MFJS-safe scalar const values, and normalize nested @@ -1340,8 +1412,9 @@ fn sanitize_kimi_parameters_candidate( normalize_kimi_compatibility(parameters, true)?; // MFJS requires `type` to live inside each anyOf branch, never alongside - // the union keyword. The root is composition-free at this point, so this - // only adjusts valid nested unions. + // the union keyword. On the pass-through shape this moves a parent-side + // type into the branches (matching the specification); the flatten path + // is composition-free at this point, so it only adjusts nested unions. sanitize_for_kimi(parameters); // JSON Schema reads an empty `items` schema as "items are unconstrained", @@ -1355,12 +1428,12 @@ fn sanitize_kimi_parameters_candidate( let Some(root) = parameters.as_object() else { return Err(KimiParameterSchemaError::RootMustBeObject); }; - if root.get("type").and_then(Value::as_str) != Some("object") - || root.contains_key("anyOf") - || root.contains_key("oneOf") - || root.contains_key("allOf") - || root.contains_key("$ref") - { + let plain_object_root = root.get("type").and_then(Value::as_str) == Some("object") + && !root.contains_key("anyOf") + && !root.contains_key("oneOf") + && !root.contains_key("allOf") + && !root.contains_key("$ref"); + if !plain_object_root && kimi_root_object_union(parameters).is_none() { return Err(KimiParameterSchemaError::RootMustBeObject); } @@ -1591,12 +1664,17 @@ pub fn validate_mfjs_parameters(parameters: &Value) -> Result<(), KimiParameterS let root = parameters .as_object() .ok_or(KimiParameterSchemaError::RootMustBeObject)?; - if root.get("type").and_then(Value::as_str) != Some("object") - || root.contains_key("anyOf") - || root.contains_key("oneOf") - || root.contains_key("allOf") - || root.contains_key("$ref") - { + // The root is either a plain `type: "object"` schema or the MFJS-legal + // object union (`anyOf` of explicit object branches, promoted from + // `oneOf` by the sanitizer); anything else stays rejected. A `type` + // beside the union is not part of either shape — the sibling check inside + // `validate_schema` rejects it with InvalidKeywordValue. + let plain_object_root = root.get("type").and_then(Value::as_str) == Some("object") + && !root.contains_key("anyOf") + && !root.contains_key("oneOf") + && !root.contains_key("allOf") + && !root.contains_key("$ref"); + if !plain_object_root && kimi_root_object_union(parameters).is_none() { return Err(KimiParameterSchemaError::RootMustBeObject); } if serde_json::to_vec(parameters) @@ -2335,18 +2413,209 @@ mod kimi_tests { } #[test] - fn kimi_parameters_add_type_to_oneof_root() { + fn kimi_parameters_passes_root_anyof_of_object_branches_through() { + // The 2026-06 incident shape (root anyOf, per-branch required groups) + // used to be flattened into one merged object, erasing every branch's + // own required set. The walle MFJS specification + // (https://github.com/MoonshotAI/walle/blob/main/docs/mfjs-spec.zh.md) + // now lists `anyOf` as a first-class applicator — branches must be + // objects and `type` must live inside them — and a live probe of + // https://api.kimi.com/coding/v1 on 2026-11-04 returned 200 with + // per-branch required groups honored, so the union must survive. + let mut schema = json!({ + "anyOf": [ + { + "type": "object", + "properties": { + "kind": {"type": "string"}, + "outline": {"type": "string"} + }, + "required": ["kind", "outline"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": {"type": "string"}, + "content": {"type": "string"} + }, + "required": ["kind", "content"], + "additionalProperties": false + } + ] + }); + + sanitize_for_kimi_parameters(&mut schema).unwrap(); + + let branches = schema["anyOf"].as_array().expect("root union survives"); + assert_eq!(branches.len(), 2); + assert_eq!( + branches[0]["required"], + json!(["kind", "outline"]), + "each branch keeps its own required group" + ); + assert_eq!(branches[1]["required"], json!(["kind", "content"])); + assert!( + branches.iter().all(|branch| branch["type"] == "object"), + "MFJS requires the object type inside every branch: {schema}" + ); + assert!( + !schema.as_object().unwrap().contains_key("type"), + "type may not sit beside the union keyword on the wire" + ); + validate_mfjs_parameters(&schema).unwrap(); + } + + #[test] + fn kimi_parameters_promotes_root_oneof_of_object_branches_to_anyof() { + // oneOf stays outside the MFJS specification, so a root oneOf of + // object branches is promoted to the documented anyOf union instead + // of being flattened. let mut schema = json!({ "oneOf": [ - {"type": "object", "properties": {"id": {"type": "integer"}}}, - {"type": "object", "properties": {"name": {"type": "string"}}} + { + "type": "object", + "properties": {"id": {"type": "integer"}}, + "required": ["id"] + }, + { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"] + } ] }); + sanitize_for_kimi_parameters(&mut schema).unwrap(); - assert_eq!(schema["type"], "object"); + assert!(schema.get("oneOf").is_none()); - assert_eq!(schema["properties"]["id"]["type"], "integer"); - assert_eq!(schema["properties"]["name"]["type"], "string"); + let branches = schema["anyOf"].as_array().expect("promoted union"); + assert_eq!(branches.len(), 2); + assert_eq!(branches[0]["required"], json!(["id"])); + assert_eq!(branches[1]["required"], json!(["name"])); + validate_mfjs_parameters(&schema).unwrap(); + } + + #[test] + fn kimi_parameters_pushes_root_type_into_object_union_branches() { + // MFJS forbids `type` beside the union keyword; the compatibility + // pushdown moves it into every branch so the sent shape matches the + // specification (the endpoint also accepted the parent-side type, so + // the pushdown is a conformance nicety, not a 400 guard). + let mut schema = json!({ + "type": "object", + "anyOf": [ + { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"] + }, + { + "type": "object", + "properties": {"url": {"type": "string"}}, + "required": ["url"] + } + ] + }); + + sanitize_for_kimi_parameters(&mut schema).unwrap(); + + assert!( + !schema.as_object().unwrap().contains_key("type"), + "root type must be pushed down: {schema}" + ); + assert!(!schema.as_object().unwrap().contains_key("properties")); + let branches = schema["anyOf"].as_array().unwrap(); + assert!(branches.iter().all(|branch| branch["type"] == "object")); + validate_mfjs_parameters(&schema).unwrap(); + } + + #[test] + fn kimi_parameters_still_flattens_root_union_with_non_object_branch() { + // A branch without an explicit object type keeps the protective + // flatten: the MFJS applicator rule only admits object branches, and + // a union with e.g. a null branch has no documented wire shape. + let mut schema = json!({ + "anyOf": [ + { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"] + }, + {"type": "null"} + ] + }); + sanitize_for_kimi_parameters(&mut schema).unwrap(); + assert_eq!(schema["type"], "object"); + assert!(schema.get("anyOf").is_none()); + assert_eq!(schema["properties"]["path"]["type"], "string"); + } + + #[test] + fn kimi_parameters_still_flattens_root_union_with_extra_root_keywords() { + // Keywords beside the union that MFJS would reject as union siblings + // (here: root `properties`) keep the flatten instead of shipping a + // shape the validator must fail closed. + let mut schema = json!({ + "anyOf": [ + { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"] + }, + { + "type": "object", + "properties": {"url": {"type": "string"}}, + "required": ["url"] + } + ], + "properties": {"legacy": {"type": "string"}} + }); + sanitize_for_kimi_parameters(&mut schema).unwrap(); + assert_eq!(schema["type"], "object"); + assert!(schema.get("anyOf").is_none()); + } + + #[test] + fn mfjs_validator_accepts_union_root_but_rejects_union_siblings() { + let union_root = json!({ + "anyOf": [ + { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"] + }, + { + "type": "object", + "properties": {"url": {"type": "string"}}, + "required": ["url"] + } + ] + }); + validate_mfjs_parameters(&union_root).unwrap(); + + let mut typed_union = union_root.clone(); + typed_union + .as_object_mut() + .unwrap() + .insert("type".to_string(), json!("object")); + assert_eq!( + validate_mfjs_parameters(&typed_union).unwrap_err(), + KimiParameterSchemaError::InvalidKeywordValue, + "type may not sit beside the union keyword" + ); + + let mixed_branch_union = json!({ + "anyOf": [ + {"type": "object", "properties": {"a": {"type": "string"}}}, + {"type": "string"} + ] + }); + assert_eq!( + validate_mfjs_parameters(&mixed_branch_union).unwrap_err(), + KimiParameterSchemaError::RootMustBeObject, + "a union root with a non-object branch is not an object schema" + ); } #[test] From dc6a1c3d645ba26cc1f62469becc3532d565d809 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 23:22:41 +0800 Subject: [PATCH 06/20] fix(tui): admit probe-verified keywords on the exact Kimi Code route A 2026-11-04 live probe of https://api.kimi.com/coding/v1 verified end to end (200, and the model honored the constraints) that the endpoint accepts pattern, format, $schema, prefixItems, exclusiveMinimum, and exclusiveMaximum, plus nested allOf - keywords the walle MFJS specification does not list and that therefore risk a whole-request 400 elsewhere. The Moonshot tool-schema gate previously applied one conservative whitelist to every Moonshot route, so an MCP tool carrying any of these keywords was dropped from every Kimi Code request with only a projection warning. Make the MFJS gate route-aware. sanitize_for_kimi_parameters and validate_mfjs_parameters take a KimiParameterRoute: the exact coding endpoint (matched with the same predicate as its fixed-sampling shaping, so a user-configured base_url pointing there gets the same treatment) adds the live-verified keywords to the whitelist with value checks (strings for pattern/format/$schema root-only, array-typed prefixItems schemas, numeric exclusive bounds folded into the range check), passes nested allOf through (two-branch shapes; single-branch allOf already collapses semantics-preserving, and the provider-neutral injection's empty properties map on allOf nodes is stripped as the same no-op), and keeps every core fail-closed rule (boolean/tuple items, empty branches, inverted bounds). Platform endpoints keep the conservative whitelist unchanged - no live evidence there, and the 400-risk asymmetry stands - so per-tool degradation and the ToolProjectionWarning stay as the final safety net on the coding route too. Also port codewhale-hq e3e563204 (the #7015 review follow-up): MFJS allows items to be absent and an unconstrained array can always be satisfied, so a required array with minItems >= 1 and absent items no longer fails mfjs_schema_can_terminate with InvalidSchemaNode - the residual drop after the empty-items repair. Applied on both routes since it is upstream behavior alignment, not a whitelist relaxation. The incompatible-tool fixtures in the three Moonshot request-boundary tests move from pattern (now legal on the coding route) to patternProperties (outside both whitelists) so they keep pinning the per-tool drop, the one-projection-warning stream event, and the named-tool_choice guard. Red-green evidence: kimi_code_route_ships_probe_verified_keywords, kimi_code_route_keeps_core_mfjs_rules_strict, kimi_parameters_min_items_ without_absent_items_terminates_upstream, kimi_code_route_keeps_probe_ verified_keywords_on_wire fail against the disabled relaxed layer (and the old terminator) and pass after; platform_route_keeps_the_conservative_ whitelist and moonshot_platform_route_keeps_the_conservative_whitelist pin unchanged platform behavior on both the validator and the wire; moonshot_drops_only_incompatible_tool, moonshot_rejects_named_choice_for_ omitted_tool, and moonshot_stream_emits_one_projection_warning keep the degradation path green. client:: tools:: gate 2639 green. Signed-off-by: asto --- crates/tui/src/client.rs | 43 +- crates/tui/src/client/chat.rs | 164 ++++- crates/tui/src/tools/schema_sanitize.rs | 790 ++++++++++++++++++------ crates/tui/src/tools/subagent/tests.rs | 14 +- 4 files changed, 810 insertions(+), 201 deletions(-) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 902591077d..986086c96d 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -6348,7 +6348,11 @@ mod tests { parameters["properties"]["query"].get("nullable").is_none(), "{parameters}" ); - crate::tools::schema_sanitize::validate_mfjs_parameters(parameters).unwrap(); + crate::tools::schema_sanitize::validate_mfjs_parameters( + parameters, + crate::tools::schema_sanitize::KimiParameterRoute::KimiCode, + ) + .unwrap(); } async fn assert_kimi_code_captures_exact_general_child_catalog() { @@ -6376,8 +6380,11 @@ mod tests { // production errors remain fixed and non-secret. for tool in &tools { let mut parameters = tool.input_schema.clone(); - crate::tools::schema_sanitize::sanitize_for_kimi_parameters(&mut parameters) - .unwrap_or_else(|error| panic!("General child tool {}: {error}", tool.name)); + crate::tools::schema_sanitize::sanitize_for_kimi_parameters( + &mut parameters, + crate::tools::schema_sanitize::KimiParameterRoute::KimiCode, + ) + .unwrap_or_else(|error| panic!("General child tool {}: {error}", tool.name)); } let mut request = k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("low"), false); @@ -6417,7 +6424,11 @@ mod tests { tool["function"]["name"] ); } - crate::tools::schema_sanitize::validate_mfjs_parameters(parameters).unwrap(); + crate::tools::schema_sanitize::validate_mfjs_parameters( + parameters, + crate::tools::schema_sanitize::KimiParameterRoute::KimiCode, + ) + .unwrap(); } } @@ -6600,11 +6611,13 @@ mod tests { k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("low"), streaming); let good = test_tool("compatible_lookup"); let mut bad = test_tool("mcp_pattern_tool"); + // `pattern` itself is live-verified legal on the Kimi Code route since + // 2026-11-04, so the incompatible-tool fixture uses `patternProperties`, + // which stays outside both route whitelists. bad.input_schema = json!({ "type": "object", - "properties": { - "query": {"type": "string", "pattern": "^private-regex-7701$"} - } + "properties": {"query": {"type": "string"}}, + "patternProperties": {"^private-regex-7701$": {"type": "string"}} }); request.tools = Some(vec![good, bad]); request.tool_choice = Some(json!("auto")); @@ -6698,11 +6711,13 @@ mod tests { k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("low"), streaming); let good = test_tool("compatible_lookup"); let mut bad = test_tool("mcp_pattern_tool"); + // `pattern` itself is live-verified legal on the Kimi Code route since + // 2026-11-04, so the incompatible-tool fixture uses `patternProperties`, + // which stays outside both route whitelists. bad.input_schema = json!({ "type": "object", - "properties": { - "query": {"type": "string", "pattern": "^private-regex-8801$"} - } + "properties": {"query": {"type": "string"}}, + "patternProperties": {"^private-regex-8801$": {"type": "string"}} }); request.tools = Some(vec![good, bad]); request.tool_choice = Some(json!({ @@ -6766,11 +6781,13 @@ mod tests { let mut request = k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("low"), true); let good = test_tool("compatible_lookup"); let mut bad = test_tool("mcp_pattern_tool"); + // `pattern` itself is live-verified legal on the Kimi Code route since + // 2026-11-04, so the incompatible-tool fixture uses `patternProperties`, + // which stays outside both route whitelists. bad.input_schema = json!({ "type": "object", - "properties": { - "query": {"type": "string", "pattern": "^private-regex-9901$"} - } + "properties": {"query": {"type": "string"}}, + "patternProperties": {"^private-regex-9901$": {"type": "string"}} }); request.tools = Some(vec![good, bad]); request.tool_choice = Some(json!("auto")); diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 23afb503b8..43806e64b6 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -1116,7 +1116,10 @@ fn mirror_minimax_reasoning_details_for_body(body: &mut Value, provider: ApiProv /// whitelist relaxation either. /// /// Returns the names of the dropped tools, in catalog order. -fn sanitize_moonshot_chat_tools(chat_tools: &mut Vec) -> Vec { +fn sanitize_moonshot_chat_tools( + chat_tools: &mut Vec, + route: crate::tools::schema_sanitize::KimiParameterRoute, +) -> Vec { let mut dropped = Vec::new(); chat_tools.retain_mut(|tool| { let Some(function) = tool @@ -1129,7 +1132,7 @@ fn sanitize_moonshot_chat_tools(chat_tools: &mut Vec) -> Vec { let Some(parameters) = function.get_mut("parameters") else { return true; }; - match crate::tools::schema_sanitize::sanitize_for_kimi_parameters(parameters) { + match crate::tools::schema_sanitize::sanitize_for_kimi_parameters(parameters, route) { Ok(note) => { if let Some(note) = note { let description = function @@ -1247,7 +1250,12 @@ pub(crate) fn build_chat_wire_body( // parameters cannot pass MFJS validation so one incompatible tool // never sinks the whole request. if matches!(provider, crate::config::ApiProvider::Moonshot) { - omitted_tool_names = sanitize_moonshot_chat_tools(&mut chat_tools); + let route = if moonshot_base_url_is_exact_kimi_code(base_url) { + crate::tools::schema_sanitize::KimiParameterRoute::KimiCode + } else { + crate::tools::schema_sanitize::KimiParameterRoute::Platform + }; + omitted_tool_names = sanitize_moonshot_chat_tools(&mut chat_tools, route); } // xAI rejects a parameters root that is not a plain object schema // (e.g. apply_patch's root `oneOf` required-groups) with a 400. @@ -8710,6 +8718,156 @@ mod image_block_wire_tests { "branch properties must not be merged into the root: {parameters}" ); } + + fn single_tool_request(name: &str, schema: serde_json::Value) -> MessageRequest { + MessageRequest { + model: crate::config::KIMI_CODE_K3_MODEL.to_string(), + messages: vec![Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "probe".to_string(), + cache_control: None, + }], + }], + max_tokens: 128, + system: None, + tools: Some(vec![Tool { + tool_type: None, + name: name.to_string(), + description: "Probe the route's schema whitelist.".to_string(), + input_schema: schema, + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + }]), + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: None, + stream: None, + temperature: None, + top_p: None, + } + } + + /// The 2026-11-04 live probe verified pattern, format, $schema, + /// prefixItems, exclusiveMinimum, and nested allOf end to end on the + /// exact Kimi Code endpoint: the wire must keep them. + #[test] + fn kimi_code_route_keeps_probe_verified_keywords_on_wire() { + let schema = serde_json::json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "label": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "format": "uri" + }, + "price": {"type": "number", "exclusiveMinimum": 0}, + "row": { + "type": "array", + "prefixItems": [{"type": "string"}], + "minItems": 1 + }, + "target": { + "type": "object", + "allOf": [ + { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"] + }, + { + "type": "object", + "properties": {"mode": {"type": "string"}} + } + ] + } + }, + "required": ["label"] + }); + let request = single_tool_request("probe_tool", schema); + + let body = build_chat_wire_body( + &request, + ApiProvider::Moonshot, + crate::config::DEFAULT_KIMI_CODE_BASE_URL, + false, + ) + .expect("Kimi Code wire body"); + + assert!( + body.omitted_tool_names.is_empty(), + "probe-verified keywords must not drop the tool: {:?}", + body.omitted_tool_names + ); + let parameters = &body.body["tools"][0]["function"]["parameters"]; + assert_eq!( + parameters["properties"]["label"]["pattern"], + serde_json::json!("^[a-z][a-z0-9-]*$") + ); + assert_eq!( + parameters["properties"]["label"]["format"], + serde_json::json!("uri") + ); + assert_eq!( + parameters["properties"]["price"]["exclusiveMinimum"], + serde_json::json!(0) + ); + assert_eq!( + parameters["properties"]["row"]["prefixItems"], + serde_json::json!([{"type": "string"}]) + ); + assert_eq!( + parameters["properties"]["target"]["allOf"] + .as_array() + .map(Vec::len), + Some(2), + "nested allOf must survive on the Kimi Code route: {parameters}" + ); + } + + /// Platform endpoints keep the conservative MFJS whitelist: the same + /// probe schema is not verifiable there, an unsupported keyword risks a + /// whole-request 400, and the per-tool drop plus the projection warning + /// stay the final safety net. + #[test] + fn moonshot_platform_route_keeps_the_conservative_whitelist() { + let schema = serde_json::json!({ + "type": "object", + "properties": { + "label": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "format": "uri" + } + }, + "required": ["label"] + }); + let request = single_tool_request("probe_tool", schema); + + let body = build_chat_wire_body( + &request, + ApiProvider::Moonshot, + crate::config::DEFAULT_MOONSHOT_BASE_URL, + false, + ) + .expect("platform wire body"); + + assert_eq!( + body.omitted_tool_names, + vec!["probe_tool".to_string()], + "the unsupported keyword drops only this tool on the platform route" + ); + assert!( + body.body.get("tools").is_none(), + "a fully dropped catalog must omit the tools key: {}", + body.body + ); + } } #[cfg(test)] diff --git a/crates/tui/src/tools/schema_sanitize.rs b/crates/tui/src/tools/schema_sanitize.rs index f8643a57ef..92e5584ccd 100644 --- a/crates/tui/src/tools/schema_sanitize.rs +++ b/crates/tui/src/tools/schema_sanitize.rs @@ -1191,6 +1191,16 @@ pub fn sanitize_for_kimi(schema: &mut serde_json::Value) { } } } + // The Kimi Code route admits nested allOf and prefixItems; walk their + // branch schemas so unions buried inside them still get the pushdown. + // On the platform route neither keyword survives normalization. + for composition_key in ["allOf", "prefixItems"] { + if let Some(branches) = obj.get_mut(composition_key).and_then(Value::as_array_mut) { + for branch in branches { + sanitize_for_kimi(branch); + } + } + } // If this object has `type` + `anyOf`/`oneOf`, push `type` into // each item and remove it from the parent. Otherwise leave it alone. @@ -1220,6 +1230,18 @@ pub fn sanitize_for_kimi(schema: &mut serde_json::Value) { obj.remove("properties"); } } + + // The same injection lands on nested allOf nodes (Kimi Code route), + // where the composition grammar admits no `properties` sibling; the + // empty map is the injection's own no-op, so remove it the same way. + if obj.contains_key("allOf") + && obj + .get("properties") + .and_then(Value::as_object) + .is_some_and(Map::is_empty) + { + obj.remove("properties"); + } } } @@ -1355,18 +1377,20 @@ impl KimiParameterRoute { /// pub fn sanitize_for_kimi_parameters( parameters: &mut Value, + route: KimiParameterRoute, ) -> Result, KimiParameterSchemaError> { // Work on a clone so a rejected schema remains byte-for-byte unchanged for // callers that retain the catalog and retry against another provider. let mut candidate = parameters.clone(); - let constraint_note = sanitize_kimi_parameters_candidate(&mut candidate)?; - validate_mfjs_parameters(&candidate)?; + let constraint_note = sanitize_kimi_parameters_candidate(&mut candidate, route)?; + validate_mfjs_parameters(&candidate, route)?; *parameters = candidate; Ok(constraint_note) } fn sanitize_kimi_parameters_candidate( parameters: &mut Value, + route: KimiParameterRoute, ) -> Result, KimiParameterSchemaError> { let Some(root) = parameters.as_object() else { return Err(KimiParameterSchemaError::RootMustBeObject); @@ -1408,8 +1432,11 @@ fn sanitize_kimi_parameters_candidate( // Restore nullable unions collapsed by the registry's provider-neutral // sanitizer, translate MFJS-safe scalar const values, and normalize nested // composition. This changes model-facing schema guidance only; each tool - // keeps responsibility for validating its own arguments. allOf fails closed. - normalize_kimi_compatibility(parameters, true)?; + // keeps responsibility for validating its own arguments. Nested allOf + // fails closed on the platform route; the Kimi Code route passes it + // through (live-verified accepted 2026-11-04, semantics the schema + // author's responsibility). + normalize_kimi_compatibility(parameters, true, route)?; // MFJS requires `type` to live inside each anyOf branch, never alongside // the union keyword. On the pass-through shape this moves a parent-side @@ -1444,20 +1471,21 @@ fn sanitize_kimi_parameters_candidate( /// parameters document. /// /// Empty-schema nodes sit exactly where schema values live: property values, -/// `items`, object-form `additionalProperties`, `anyOf` branches, and `$defs` -/// entries. `properties` itself is a name map, so its own emptiness is legal -/// and only its values are visited. `oneOf` needs no visit: this runs after -/// `normalize_kimi_compatibility` has converted every non-root `oneOf` to -/// `anyOf`, and root composition was already flattened before that. -/// Schema values under keywords MFJS rejects (`contains`, `prefixItems`, -/// `patternProperties`, `not`, `if`/`then`/`else`, `propertyNames`, -/// `unevaluatedProperties`, `unevaluatedItems`, nested `allOf`, tuple-form -/// `items`) need no visit: the keyword itself fails closed — -/// `UnsupportedKeyword`, `UnsupportedNestedAllOf` for a nested `allOf`, or -/// `InvalidSchemaNode` for tuple-form `items` — before a stranded empty -/// `items` node inside one could matter. `dependentSchemas` is the one -/// exception in shape: `strip_dependent_keywords` removes it (with its -/// subtree) rather than failing closed, so it cannot strand one either. +/// `items`, object-form `additionalProperties`, `anyOf` branches, `$defs` +/// entries, and — since the Kimi Code route admits nested `allOf` and +/// `prefixItems` — their branch schemas too. `properties` itself is a name +/// map, so its own emptiness is legal and only its values are visited. +/// `oneOf` needs no visit: this runs after `normalize_kimi_compatibility` has +/// converted every non-root `oneOf` to `anyOf`, and root composition was +/// already flattened before that. Schema values under the keywords MFJS still +/// rejects everywhere (`contains`, `patternProperties`, `not`, +/// `if`/`then`/`else`, `propertyNames`, `unevaluatedProperties`, +/// `unevaluatedItems`, tuple-form `items`) need no visit: the keyword itself +/// fails closed — `UnsupportedKeyword`, or `InvalidSchemaNode` for tuple-form +/// `items` — before a stranded empty `items` node inside one could matter. +/// `dependentSchemas` is the one exception in shape: +/// `strip_dependent_keywords` removes it (with its subtree) rather than +/// failing closed, so it cannot strand one either. /// /// Children are visited before the node's own `items` check so a nested /// repair can empty the parent (`"items": {"items": {}}` is vacuously true @@ -1484,6 +1512,16 @@ fn remove_empty_mfjs_items_schemas(value: &mut Value) { remove_empty_mfjs_items_schemas(branch); } } + // Branch schemas of the two composition keywords the Kimi Code route + // admits. On the platform route neither keyword survives normalization, + // so these visits see nothing there. + for composition_key in ["allOf", "prefixItems"] { + if let Some(branches) = obj.get_mut(composition_key).and_then(Value::as_array_mut) { + for branch in branches.iter_mut() { + remove_empty_mfjs_items_schemas(branch); + } + } + } if let Some(additional) = obj.get_mut("additionalProperties") && additional.is_object() { @@ -1551,13 +1589,20 @@ fn inline_internal_kimi_root_ref(parameters: &mut Value) -> Result<(), KimiParam fn normalize_kimi_compatibility( schema: &mut Value, is_root: bool, + route: KimiParameterRoute, ) -> Result<(), KimiParameterSchemaError> { let Some(obj) = schema.as_object_mut() else { return Err(KimiParameterSchemaError::InvalidSchemaNode); }; if !is_root { - if obj.contains_key("allOf") { + if obj.contains_key("allOf") && !route.is_relaxed() { + // Platform endpoints have no live evidence for nested allOf, and + // the walle specification does not list the keyword anywhere, so + // the conservative drop stays. The Kimi Code route's 2026-11-04 + // probe verified the server accepts nested allOf (200); the + // documented position for unstated keywords is "not guaranteed + // stable", so the schema author owns its semantics. return Err(KimiParameterSchemaError::UnsupportedNestedAllOf); } if let Some(one_of) = obj.remove("oneOf") { @@ -1618,11 +1663,14 @@ fn normalize_kimi_compatibility( Some(None) => return Err(KimiParameterSchemaError::InvalidNullable), } - normalize_kimi_child_schemas(schema)?; + normalize_kimi_child_schemas(schema, route)?; Ok(()) } -fn normalize_kimi_child_schemas(schema: &mut Value) -> Result<(), KimiParameterSchemaError> { +fn normalize_kimi_child_schemas( + schema: &mut Value, + route: KimiParameterRoute, +) -> Result<(), KimiParameterSchemaError> { let Some(obj) = schema.as_object_mut() else { return Err(KimiParameterSchemaError::InvalidSchemaNode); }; @@ -1630,22 +1678,33 @@ fn normalize_kimi_child_schemas(schema: &mut Value) -> Result<(), KimiParameterS for map_key in ["properties", "$defs"] { if let Some(children) = obj.get_mut(map_key).and_then(Value::as_object_mut) { for child in children.values_mut() { - normalize_kimi_compatibility(child, false)?; + normalize_kimi_compatibility(child, false, route)?; } } } if let Some(items) = obj.get_mut("items") { - normalize_kimi_compatibility(items, false)?; + normalize_kimi_compatibility(items, false, route)?; } if let Some(additional) = obj.get_mut("additionalProperties") && additional.is_object() { - normalize_kimi_compatibility(additional, false)?; + normalize_kimi_compatibility(additional, false, route)?; } if let Some(branches) = obj.get_mut("anyOf").and_then(Value::as_array_mut) { for branch in branches { - normalize_kimi_compatibility(branch, false)?; + normalize_kimi_compatibility(branch, false, route)?; + } + } + // The Kimi Code route admits nested allOf and prefixItems; normalize their + // branch schemas the same way. On the platform route a nested allOf never + // reaches here (it failed closed above) and prefixItems is rejected + // wholesale by the validator, so these visits see nothing there. + for composition_key in ["allOf", "prefixItems"] { + if let Some(branches) = obj.get_mut(composition_key).and_then(Value::as_array_mut) { + for branch in branches { + normalize_kimi_compatibility(branch, false, route)?; + } } } Ok(()) @@ -1660,7 +1719,16 @@ fn is_mfjs_enum_literal(value: &Value) -> bool { /// Every error is a fixed enum variant: schemas can originate in MCP or /// runtime tools and may contain private names or values, so diagnostics must /// never echo a keyword, property, reference, or literal from the document. -pub fn validate_mfjs_parameters(parameters: &Value) -> Result<(), KimiParameterSchemaError> { +/// +/// `route` selects the keyword whitelist: the exact Kimi Code endpoint +/// additionally admits the keywords its 2026-11-04 live probe verified +/// (`pattern`, `format`, `$schema`, `prefixItems`, `exclusiveMinimum`, +/// `exclusiveMaximum`, nested `allOf`), while platform endpoints keep the +/// conservative MFJS whitelist. +pub fn validate_mfjs_parameters( + parameters: &Value, + route: KimiParameterRoute, +) -> Result<(), KimiParameterSchemaError> { let root = parameters .as_object() .ok_or(KimiParameterSchemaError::RootMustBeObject)?; @@ -1684,7 +1752,7 @@ pub fn validate_mfjs_parameters(parameters: &Value) -> Result<(), KimiParameterS return Err(KimiParameterSchemaError::ResourceLimitExceeded); } - let mut state = MfjsValidationState::new(parameters); + let mut state = MfjsValidationState::new(parameters, route); state.validate_schema(parameters, true, false, 0, 0)?; let mut visiting_refs = HashSet::new(); @@ -1713,14 +1781,16 @@ const MFJS_MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; struct MfjsValidationState<'a> { document: &'a Value, + route: KimiParameterRoute, total_properties: usize, total_enum_values: usize, } impl<'a> MfjsValidationState<'a> { - fn new(document: &'a Value) -> Self { + fn new(document: &'a Value, route: KimiParameterRoute) -> Self { Self { document, + route, total_properties: 0, total_enum_values: 0, } @@ -1769,10 +1839,28 @@ impl<'a> MfjsValidationState<'a> { "description", "default", ]; - if obj - .keys() - .any(|keyword| !ALLOWED_KEYWORDS.contains(&keyword.as_str())) - { + const RELAXED_KEYWORDS: &[&str] = &[ + // Kimi Code route only, live-verified 2026-11-04 against + // https://api.kimi.com/coding/v1 (200, model honored the + // constraints): string constraints, tuple items, exclusive + // numeric bounds, the document identity annotation, and nested + // allOf composition. The walle specification does not list these + // keywords, so unstated-keyword semantics stay the schema + // author's responsibility; platform endpoints keep the + // conservative whitelist. + "pattern", + "format", + "$schema", + "prefixItems", + "exclusiveMinimum", + "exclusiveMaximum", + "allOf", + ]; + let relaxed = self.route.is_relaxed(); + if obj.keys().any(|keyword| { + !ALLOWED_KEYWORDS.contains(&keyword.as_str()) + && (!relaxed || !RELAXED_KEYWORDS.contains(&keyword.as_str())) + }) { return Err(KimiParameterSchemaError::UnsupportedKeyword); } @@ -1786,6 +1874,19 @@ impl<'a> MfjsValidationState<'a> { { return Err(KimiParameterSchemaError::InvalidKeywordValue); } + if obj.get("$schema").is_some_and(|value| !value.is_string()) + || (!is_root && obj.contains_key("$schema")) + { + return Err(KimiParameterSchemaError::InvalidKeywordValue); + } + for string_keyword in ["pattern", "format"] { + if obj + .get(string_keyword) + .is_some_and(|value| !value.is_string()) + { + return Err(KimiParameterSchemaError::InvalidKeywordValue); + } + } if let Some(definitions) = obj.get("$defs") { if !is_root { @@ -1837,6 +1938,34 @@ impl<'a> MfjsValidationState<'a> { return Ok(()); } + // Nested allOf on the Kimi Code route: same grammar slot as anyOf, + // plus a concrete `type` sibling (the endpoint accepted typed nodes + // live; anyOf forbids the sibling by specification, allOf is simply + // unlisted, so the merged-object reading stays available). Combined + // with anyOf the node is rejected by the sibling check above. + if let Some(all_of) = obj.get("allOf") { + let branches = all_of + .as_array() + .filter(|branches| !branches.is_empty() && branches.len() <= MFJS_MAX_ANY_OF_ITEMS) + .ok_or(KimiParameterSchemaError::ResourceLimitExceeded)?; + let allowed_composition_sibling = |key: &str| { + matches!(key, "allOf" | "title" | "description" | "type") + || (is_root && matches!(key, "$defs" | "$id" | "$schema")) + }; + if obj.keys().any(|key| !allowed_composition_sibling(key)) { + return Err(KimiParameterSchemaError::InvalidKeywordValue); + } + if let Some(schema_type) = obj.get("type").and_then(Value::as_str) + && !is_mfjs_concrete_type(schema_type) + { + return Err(KimiParameterSchemaError::InvalidKeywordValue); + } + for branch in branches { + self.validate_schema(branch, false, false, property_depth, structural_depth + 1)?; + } + return Ok(()); + } + let schema_type = obj .get("type") .and_then(Value::as_str) @@ -1844,7 +1973,7 @@ impl<'a> MfjsValidationState<'a> { .ok_or(KimiParameterSchemaError::MissingType)?; if obj .keys() - .any(|keyword| !mfjs_keyword_allowed_for_type(keyword, schema_type, is_root)) + .any(|keyword| !mfjs_keyword_allowed_for_type(keyword, schema_type, is_root, relaxed)) { return Err(KimiParameterSchemaError::InvalidKeywordValue); } @@ -1923,6 +2052,19 @@ impl<'a> MfjsValidationState<'a> { self.validate_schema(items, false, false, property_depth, structural_depth + 1)?; } + // Tuple items on the Kimi Code route: each prefix schema is validated + // like an items schema. The keyword only reaches this point for an + // array-typed node (see `mfjs_keyword_allowed_for_type`). + if let Some(prefix_items) = obj.get("prefixItems") { + let branches = prefix_items + .as_array() + .filter(|branches| branches.len() <= MFJS_MAX_ANY_OF_ITEMS) + .ok_or(KimiParameterSchemaError::ResourceLimitExceeded)?; + for branch in branches { + self.validate_schema(branch, false, false, property_depth, structural_depth + 1)?; + } + } + validate_mfjs_bounds(obj, schema_type)?; Ok(()) } @@ -1935,19 +2077,34 @@ fn is_mfjs_concrete_type(schema_type: &str) -> bool { ) } -fn mfjs_keyword_allowed_for_type(keyword: &str, schema_type: &str, is_root: bool) -> bool { +fn mfjs_keyword_allowed_for_type( + keyword: &str, + schema_type: &str, + is_root: bool, + relaxed: bool, +) -> bool { if is_root && matches!(keyword, "$defs" | "$id") { return true; } + if relaxed && is_root && keyword == "$schema" { + return true; + } if matches!(keyword, "type" | "title" | "description") { return true; } match schema_type { "object" => matches!(keyword, "properties" | "required" | "additionalProperties"), - "array" => matches!(keyword, "items" | "minItems" | "maxItems"), - "string" => matches!(keyword, "enum" | "default" | "minLength" | "maxLength"), + "array" => { + matches!(keyword, "items" | "minItems" | "maxItems") + || (relaxed && keyword == "prefixItems") + } + "string" => { + matches!(keyword, "enum" | "default" | "minLength" | "maxLength") + || (relaxed && matches!(keyword, "pattern" | "format")) + } "number" | "integer" => { matches!(keyword, "enum" | "default" | "minimum" | "maximum") + || (relaxed && matches!(keyword, "exclusiveMinimum" | "exclusiveMaximum")) } "boolean" | "null" => keyword == "default", _ => false, @@ -2046,23 +2203,34 @@ fn validate_mfjs_bounds( validate_mfjs_unsigned_range(obj, schema_type, "string", "minLength", "maxLength")?; validate_mfjs_unsigned_range(obj, schema_type, "array", "minItems", "maxItems")?; - let minimum = obj.get("minimum"); - let maximum = obj.get("maximum"); - if minimum.is_some() || maximum.is_some() { - let parse_bound = |value: &Value| match schema_type { - "integer" => mfjs_integer_value(value), - "number" => value.as_number().and_then(serde_json::Number::as_f64), - _ => None, - }; - let minimum = minimum - .map(|value| parse_bound(value).ok_or(KimiParameterSchemaError::InvalidRange)) - .transpose()?; - let maximum = maximum + // The exclusive bounds only reach this point on the Kimi Code route + // (whitelist-gated), but presence is the only gate needed here: on the + // platform route the keyword never survives the whitelist, so the check + // is a no-op there. + let lower_values = [obj.get("minimum"), obj.get("exclusiveMinimum")]; + let upper_values = [obj.get("maximum"), obj.get("exclusiveMaximum")]; + if lower_values.iter().all(Option::is_none) && upper_values.iter().all(Option::is_none) { + return Ok(()); + } + let parse_bound = |value: &Value| match schema_type { + "integer" => mfjs_integer_value(value), + "number" => value.as_number().and_then(serde_json::Number::as_f64), + _ => None, + }; + let parse_side = |values: &[Option<&Value>]| { + values + .iter() + .flatten() .map(|value| parse_bound(value).ok_or(KimiParameterSchemaError::InvalidRange)) - .transpose()?; - if minimum.zip(maximum).is_some_and(|(min, max)| min > max) { - return Err(KimiParameterSchemaError::InvalidRange); - } + .collect::, KimiParameterSchemaError>>() + }; + let lowers = parse_side(&lower_values)?; + let uppers = parse_side(&upper_values)?; + if lowers + .iter() + .any(|lower| uppers.iter().any(|upper| lower > upper)) + { + return Err(KimiParameterSchemaError::InvalidRange); } Ok(()) } @@ -2143,6 +2311,18 @@ fn mfjs_schema_can_terminate( return Ok(false); } + // Nested allOf (Kimi Code route): a conjunct terminates only when every + // branch does. The keyword is whitelist-gated, so platform documents + // never reach this arm. + if let Some(branches) = obj.get("allOf").and_then(Value::as_array) { + for branch in branches { + if !mfjs_schema_can_terminate(branch, document, visiting_refs, structural_depth + 1)? { + return Ok(false); + } + } + return Ok(true); + } + match obj.get("type").and_then(Value::as_str) { Some("object") => { let Some(required) = obj.get("required").and_then(Value::as_array) else { @@ -2177,12 +2357,35 @@ fn mfjs_schema_can_terminate( // values are unreachable here only because `validate_mfjs_bounds` // rejects them (InvalidRange) on every node before the terminator // runs — keep that call order if the walkers are reordered. - if obj.get("minItems").and_then(Value::as_u64).unwrap_or(0) == 0 { + let min_items = obj.get("minItems").and_then(Value::as_u64).unwrap_or(0); + if min_items == 0 { return Ok(true); } - let items = obj - .get("items") - .ok_or(KimiParameterSchemaError::InvalidSchemaNode)?; + // Ported from codewhale-hq e3e563204 (the #7015 review + // follow-up): MFJS allows `items` to be absent, and an + // unconstrained array can always be satisfied, so absent `items` + // counts as terminating instead of failing with + // InvalidSchemaNode. Tuple `prefixItems` entries still bind the + // first `minItems` elements, so those prefixes are checked when + // present. + if let Some(prefix_items) = obj.get("prefixItems").and_then(Value::as_array) { + let count = usize::try_from(min_items) + .unwrap_or(usize::MAX) + .min(prefix_items.len()); + for branch in &prefix_items[..count] { + if !mfjs_schema_can_terminate( + branch, + document, + visiting_refs, + structural_depth + 1, + )? { + return Ok(false); + } + } + } + let Some(items) = obj.get("items") else { + return Ok(true); + }; mfjs_schema_can_terminate(items, document, visiting_refs, structural_depth + 1) } Some(schema_type) if is_mfjs_concrete_type(schema_type) => Ok(true), @@ -2264,6 +2467,21 @@ fn validate_mfjs_expanded_depth( )?; } } + // Nested allOf and tuple prefixItems (Kimi Code route): their branch + // schemas are ordinary schema positions the depth walker must visit. + for composition_key in ["allOf", "prefixItems"] { + if let Some(branches) = obj.get(composition_key).and_then(Value::as_array) { + for branch in branches { + validate_mfjs_expanded_depth( + branch, + document, + property_depth, + visiting_refs, + structural_depth + 1, + )?; + } + } + } Ok(()) } @@ -2358,7 +2576,7 @@ mod kimi_tests { #[test] fn kimi_parameters_add_type_to_empty_root() { let mut schema = json!({}); - sanitize_for_kimi_parameters(&mut schema).unwrap(); + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); assert_eq!(schema, json!({"type": "object", "properties": {}})); } @@ -2371,7 +2589,7 @@ mod kimi_tests { "required": ["path"] }); - sanitize_for_kimi_parameters(&mut schema).unwrap(); + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); assert_eq!(schema["type"], "object"); assert_eq!(schema["properties"]["path"]["type"], "string"); @@ -2393,7 +2611,7 @@ mod kimi_tests { {"type": "null"} ] }); - sanitize_for_kimi_parameters(&mut schema).unwrap(); + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); assert_eq!(schema["type"], "object"); assert!(schema.get("anyOf").is_none()); assert_eq!(schema["properties"]["path"]["type"], "string"); @@ -2406,7 +2624,7 @@ mod kimi_tests { {"type": "object", "properties": {"name": {"type": "string"}}} ] }); - sanitize_for_kimi_parameters(&mut schema).unwrap(); + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); assert_eq!(schema["type"], "object"); assert!(schema.get("allOf").is_none()); assert_eq!(schema["properties"]["name"]["type"], "string"); @@ -2445,7 +2663,7 @@ mod kimi_tests { ] }); - sanitize_for_kimi_parameters(&mut schema).unwrap(); + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); let branches = schema["anyOf"].as_array().expect("root union survives"); assert_eq!(branches.len(), 2); @@ -2463,7 +2681,7 @@ mod kimi_tests { !schema.as_object().unwrap().contains_key("type"), "type may not sit beside the union keyword on the wire" ); - validate_mfjs_parameters(&schema).unwrap(); + validate_mfjs_parameters(&schema, KimiParameterRoute::Platform).unwrap(); } #[test] @@ -2486,14 +2704,14 @@ mod kimi_tests { ] }); - sanitize_for_kimi_parameters(&mut schema).unwrap(); + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); assert!(schema.get("oneOf").is_none()); let branches = schema["anyOf"].as_array().expect("promoted union"); assert_eq!(branches.len(), 2); assert_eq!(branches[0]["required"], json!(["id"])); assert_eq!(branches[1]["required"], json!(["name"])); - validate_mfjs_parameters(&schema).unwrap(); + validate_mfjs_parameters(&schema, KimiParameterRoute::Platform).unwrap(); } #[test] @@ -2518,7 +2736,7 @@ mod kimi_tests { ] }); - sanitize_for_kimi_parameters(&mut schema).unwrap(); + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); assert!( !schema.as_object().unwrap().contains_key("type"), @@ -2527,7 +2745,7 @@ mod kimi_tests { assert!(!schema.as_object().unwrap().contains_key("properties")); let branches = schema["anyOf"].as_array().unwrap(); assert!(branches.iter().all(|branch| branch["type"] == "object")); - validate_mfjs_parameters(&schema).unwrap(); + validate_mfjs_parameters(&schema, KimiParameterRoute::Platform).unwrap(); } #[test] @@ -2545,7 +2763,7 @@ mod kimi_tests { {"type": "null"} ] }); - sanitize_for_kimi_parameters(&mut schema).unwrap(); + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); assert_eq!(schema["type"], "object"); assert!(schema.get("anyOf").is_none()); assert_eq!(schema["properties"]["path"]["type"], "string"); @@ -2571,7 +2789,7 @@ mod kimi_tests { ], "properties": {"legacy": {"type": "string"}} }); - sanitize_for_kimi_parameters(&mut schema).unwrap(); + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); assert_eq!(schema["type"], "object"); assert!(schema.get("anyOf").is_none()); } @@ -2592,7 +2810,7 @@ mod kimi_tests { } ] }); - validate_mfjs_parameters(&union_root).unwrap(); + validate_mfjs_parameters(&union_root, KimiParameterRoute::Platform).unwrap(); let mut typed_union = union_root.clone(); typed_union @@ -2600,7 +2818,7 @@ mod kimi_tests { .unwrap() .insert("type".to_string(), json!("object")); assert_eq!( - validate_mfjs_parameters(&typed_union).unwrap_err(), + validate_mfjs_parameters(&typed_union, KimiParameterRoute::Platform).unwrap_err(), KimiParameterSchemaError::InvalidKeywordValue, "type may not sit beside the union keyword" ); @@ -2612,7 +2830,8 @@ mod kimi_tests { ] }); assert_eq!( - validate_mfjs_parameters(&mixed_branch_union).unwrap_err(), + validate_mfjs_parameters(&mixed_branch_union, KimiParameterRoute::Platform) + .unwrap_err(), KimiParameterSchemaError::RootMustBeObject, "a union root with a non-object branch is not an object schema" ); @@ -2622,7 +2841,7 @@ mod kimi_tests { fn kimi_parameters_flattens_actual_apply_patch_root_and_returns_constraint_note() { let mut schema = ApplyPatchTool.input_schema(); - let note = sanitize_for_kimi_parameters(&mut schema).unwrap(); + let note = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); assert_eq!(schema["type"], "object"); assert!(schema.get("oneOf").is_none()); @@ -2654,7 +2873,7 @@ mod kimi_tests { } }); - sanitize_for_kimi_parameters(&mut schema).unwrap(); + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); assert_eq!(schema["type"], "object"); let selector = &schema["properties"]["selector"]; @@ -2679,7 +2898,7 @@ mod kimi_tests { } }); - sanitize_for_kimi_parameters(&mut schema).unwrap(); + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); let selector = &schema["properties"]["selector"]; assert!(selector.get("oneOf").is_none()); @@ -2708,7 +2927,7 @@ mod kimi_tests { assert_eq!(schema["properties"]["query"]["nullable"], true); assert!(schema["properties"]["query"].get("anyOf").is_none()); - sanitize_for_kimi_parameters(&mut schema).unwrap(); + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); let query = &schema["properties"]["query"]; assert!(query.get("nullable").is_none(), "{query}"); @@ -2716,7 +2935,7 @@ mod kimi_tests { query["anyOf"], json!([{"type": "string"}, {"type": "null"}]) ); - validate_mfjs_parameters(&schema).unwrap(); + validate_mfjs_parameters(&schema, KimiParameterRoute::Platform).unwrap(); } #[test] @@ -2742,7 +2961,7 @@ mod kimi_tests { } }); - sanitize_for_kimi_parameters(&mut schema).unwrap(); + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); let kind = schema .pointer("/properties/envelope/properties/items/items/properties/kind") @@ -2761,7 +2980,8 @@ mod kimi_tests { }); let original = schema.clone(); - let error = sanitize_for_kimi_parameters(&mut schema).unwrap_err(); + let error = + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap_err(); assert_eq!(error, KimiParameterSchemaError::UnsupportedConstLiteral); assert!(!error.to_string().contains("private-toggle-8172")); @@ -2781,7 +3001,8 @@ mod kimi_tests { }); let original = schema.clone(); - let error = sanitize_for_kimi_parameters(&mut schema).unwrap_err(); + let error = + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap_err(); let diagnostic = error.to_string(); assert_eq!(error, KimiParameterSchemaError::UnsupportedKeyword); @@ -2820,7 +3041,8 @@ mod kimi_tests { ), ] { let original = schema.clone(); - let error = sanitize_for_kimi_parameters(&mut schema).unwrap_err(); + let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) + .unwrap_err(); assert_eq!(error, expected); for sentinel in sentinels { assert!(!error.to_string().contains(sentinel)); @@ -2840,7 +3062,7 @@ mod kimi_tests { } }); - sanitize_for_kimi_parameters(&mut schema).unwrap(); + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); assert_eq!(schema["properties"]["kind"]["type"], "string"); assert_eq!(schema["properties"]["kind"]["enum"], json!(["var_handle"])); @@ -2856,7 +3078,7 @@ mod kimi_tests { "type": "object", "additionalProperties": {} }); - sanitize_for_kimi_parameters(&mut schema).unwrap(); + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); assert_eq!(schema["additionalProperties"], json!({})); let mut invalid = json!({ @@ -2864,7 +3086,7 @@ mod kimi_tests { "properties": {"value": {}} }); assert_eq!( - sanitize_for_kimi_parameters(&mut invalid).unwrap_err(), + sanitize_for_kimi_parameters(&mut invalid, KimiParameterRoute::Platform).unwrap_err(), KimiParameterSchemaError::MissingType ); } @@ -2886,7 +3108,7 @@ mod kimi_tests { "required": ["function"], "additionalProperties": true }); - sanitize_for_kimi_parameters(&mut schema) + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) .expect("empty items must repair to the keyword being absent"); assert_eq!(schema["properties"]["args"], json!({"type": "array"})); @@ -2904,7 +3126,8 @@ mod kimi_tests { } } }); - sanitize_for_kimi_parameters(&mut nested).expect("nested empty items repair"); + sanitize_for_kimi_parameters(&mut nested, KimiParameterRoute::Platform) + .expect("nested empty items repair"); assert_eq!( nested["properties"]["outer"]["properties"]["inner"], json!({"type": "array"}) @@ -2917,7 +3140,8 @@ mod kimi_tests { } }); assert_eq!( - sanitize_for_kimi_parameters(&mut described_only).unwrap_err(), + sanitize_for_kimi_parameters(&mut described_only, KimiParameterRoute::Platform) + .unwrap_err(), KimiParameterSchemaError::MissingType ); } @@ -2935,7 +3159,8 @@ mod kimi_tests { "grid": {"type": "array", "items": {"items": {}}} } }); - sanitize_for_kimi_parameters(&mut grid).expect("items-in-items repair"); + sanitize_for_kimi_parameters(&mut grid, KimiParameterRoute::Platform) + .expect("items-in-items repair"); assert_eq!(grid["properties"]["grid"], json!({"type": "array"})); // A root `items` keyword beside `$defs`: the repair must visit every @@ -2947,7 +3172,8 @@ mod kimi_tests { "$defs": {"rows": {"type": "array", "items": {}}}, "properties": {"rows": {"$ref": "#/$defs/rows"}} }); - sanitize_for_kimi_parameters(&mut with_defs).expect("root items beside $defs repair"); + sanitize_for_kimi_parameters(&mut with_defs, KimiParameterRoute::Platform) + .expect("root items beside $defs repair"); assert!(with_defs.get("items").is_none()); assert_eq!(with_defs["$defs"]["rows"], json!({"type": "array"})); @@ -2971,7 +3197,7 @@ mod kimi_tests { } } }); - sanitize_for_kimi_parameters(&mut nested_positions) + sanitize_for_kimi_parameters(&mut nested_positions, KimiParameterRoute::Platform) .expect("additionalProperties and anyOf empty items repair"); assert_eq!( nested_positions["properties"]["map"]["additionalProperties"], @@ -2990,7 +3216,7 @@ mod kimi_tests { "properties": {"name": {"type": "string"}}, "additionalProperties": {"type": "array", "items": {}} }); - sanitize_for_kimi_parameters(&mut root_additional) + sanitize_for_kimi_parameters(&mut root_additional, KimiParameterRoute::Platform) .expect("root additionalProperties empty items repair"); assert_eq!( root_additional["additionalProperties"], @@ -3006,7 +3232,7 @@ mod kimi_tests { "properties": {"a": {"$ref": "#/$defs/a", "items": {}}}, "$defs": {"a": {"type": "string"}} }); - sanitize_for_kimi_parameters(&mut ref_with_items) + sanitize_for_kimi_parameters(&mut ref_with_items, KimiParameterRoute::Platform) .expect("$ref with an empty items sibling repairs"); assert_eq!( ref_with_items["properties"]["a"], @@ -3015,21 +3241,59 @@ mod kimi_tests { } #[test] - fn kimi_parameters_min_items_beside_empty_items_is_position_dependent() { - // Terminator-checked position (a required property): the repaired - // node must still terminate, and with `minItems >= 1` the terminator - // requires an `items` keyword — the tool keeps failing closed, with - // the terminator's variant. + fn kimi_parameters_min_items_without_absent_items_terminates_upstream() { + // Ported from codewhale-hq e3e563204 (the #7015 review follow-up): + // once an empty `items` repairs to the keyword being absent, a + // required array with `minItems >= 1` no longer fails + // mfjs_schema_can_terminate with InvalidSchemaNode — MFJS allows + // `items` to be absent, and an unconstrained array can always be + // satisfied, so the repaired tool ships at every terminator-checked + // position. let mut schema = json!({ "type": "object", "properties": {"a": {"type": "array", "items": {}, "minItems": 1}}, "required": ["a"] }); - let error = sanitize_for_kimi_parameters(&mut schema) - .expect_err("minItems >= 1 cannot terminate without items at a checked position"); - assert!( - matches!(error, KimiParameterSchemaError::InvalidSchemaNode), - "the terminator's variant is InvalidSchemaNode, not MissingType: {error:?}" + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) + .expect("a required minItems >= 1 array without items terminates"); + assert_eq!( + schema["properties"]["a"], + json!({"type": "array", "minItems": 1}) + ); + + // The same port applies at the terminator's other checked positions: + // an `anyOf` branch and a `$ref` target reached from a required + // property. + let mut schema = json!({ + "type": "object", + "properties": { + "a": { + "anyOf": [ + {"type": "array", "items": {}, "minItems": 1}, + {"type": "string"} + ] + } + }, + "required": ["a"] + }); + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) + .expect("a minItems >= 1 anyOf branch without items terminates"); + assert_eq!( + schema["properties"]["a"]["anyOf"][0], + json!({"type": "array", "minItems": 1}) + ); + + let mut schema = json!({ + "type": "object", + "$defs": {"a": {"type": "array", "items": {}, "minItems": 1}}, + "properties": {"x": {"$ref": "#/$defs/a"}}, + "required": ["x"] + }); + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) + .expect("a minItems >= 1 $ref target without items terminates"); + assert_eq!( + schema["$defs"]["a"], + json!({"type": "array", "minItems": 1}) ); // `minItems: 0` terminates trivially at the same checked position, @@ -3040,90 +3304,239 @@ mod kimi_tests { "properties": {"a": {"type": "array", "items": {}, "minItems": 0}}, "required": ["a"] }); - sanitize_for_kimi_parameters(&mut schema).expect("minItems: 0 beside empty items repairs"); + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) + .expect("minItems: 0 beside empty items repairs"); assert_eq!( schema["properties"]["a"], json!({"type": "array", "minItems": 0}) ); - // A non-required property position is never revisited by the - // terminator, so the repaired tool SHIPS — pre-repair it was dropped - // with MissingType. The shipped shape is instance-set-equivalent: - // the empty `items` was a no-op constraint and `minItems` survives. - let mut schema = json!({ + // Tuple semantics stay protected: with `prefixItems` present the + // first `minItems` prefix entries are still required, so a + // non-terminating prefix keeps the tool dropped. + let schema = json!({ "type": "object", "properties": { - "s": {"type": "array", "items": {}, "minItems": 1} - } + "a": { + "type": "array", + "minItems": 1, + "prefixItems": [{"type": "string"}] + } + }, + "required": ["a"] }); - sanitize_for_kimi_parameters(&mut schema) - .expect("a non-required position ships the equivalent repaired schema"); - assert_eq!( - schema["properties"]["s"], - json!({"type": "array", "minItems": 1}) - ); + validate_mfjs_parameters(&schema, KimiParameterRoute::Platform) + .expect_err("prefixItems is outside the platform whitelist"); + validate_mfjs_parameters(&schema, KimiParameterRoute::KimiCode) + .expect("a terminating tuple prefix ships on the Kimi Code route"); - // The terminator's other two checked positions — an `anyOf` branch - // and a `$ref` target, each reached from a required property — fail - // closed the same way, with the terminator's variant. - let mut schema = json!({ + let schema = json!({ "type": "object", "properties": { "a": { - "anyOf": [ - {"type": "array", "items": {}, "minItems": 1}, - {"type": "string"} + "type": "array", + "minItems": 1, + "prefixItems": [ + {"type": "array", "minItems": 1, "items": {"$ref": "#"}} ] } }, "required": ["a"] }); - let error = sanitize_for_kimi_parameters(&mut schema) - .expect_err("minItems >= 1 cannot terminate in a checked anyOf branch"); + validate_mfjs_parameters(&schema, KimiParameterRoute::KimiCode) + .expect_err("a required non-terminating tuple prefix still fails closed"); + } + + fn probe_verified_keyword_schema() -> Value { + // Every keyword the 2026-11-04 live probe of + // https://api.kimi.com/coding/v1 verified end to end (200, model + // honored pattern/prefixItems semantics), plus nested allOf which the + // same probe found accepted by the server. + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "label": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "format": "uri", + "maxLength": 40 + }, + "price": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 100 + }, + "rank": {"type": "integer", "exclusiveMaximum": 10}, + "row": { + "type": "array", + "prefixItems": [{"type": "string"}, {"type": "integer"}], + "minItems": 2, + "maxItems": 2 + }, + "target": { + "type": "object", + "allOf": [ + { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"] + }, + { + "type": "object", + "properties": {"mode": {"type": "string"}} + } + ] + } + }, + "required": ["label"] + }) + } + + #[test] + fn kimi_code_route_ships_probe_verified_keywords() { + let mut schema = probe_verified_keyword_schema(); + let note = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::KimiCode) + .expect("every probe-verified keyword ships on the Kimi Code route"); + assert!(note.is_none()); + assert_eq!( + schema["properties"]["label"]["pattern"], + json!("^[a-z][a-z0-9-]*$") + ); + assert_eq!(schema["properties"]["label"]["format"], json!("uri")); + assert_eq!( + schema["$schema"], + json!("https://json-schema.org/draft/2020-12/schema") + ); + assert_eq!(schema["properties"]["price"]["exclusiveMinimum"], json!(0)); + assert_eq!(schema["properties"]["rank"]["exclusiveMaximum"], json!(10)); + assert_eq!( + schema["properties"]["row"]["prefixItems"], + json!([{"type": "string"}, {"type": "integer"}]) + ); + assert_eq!(schema["properties"]["row"]["minItems"], json!(2)); + assert_eq!( + schema["properties"]["target"]["allOf"] + .as_array() + .map(Vec::len), + Some(2), + "nested allOf passes through on the Kimi Code route: {schema}" + ); + validate_mfjs_parameters(&schema, KimiParameterRoute::KimiCode).unwrap(); + } + + #[test] + fn platform_route_keeps_the_conservative_whitelist() { + // The same document against the platform route must keep failing + // closed: no live probe backs the extra keywords there, the walle + // specification does not list them, and an unsupported keyword on a + // platform endpoint risks a whole-request 400 — the asymmetry that + // keeps that route strict. + let mut schema = probe_verified_keyword_schema(); + let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) + .expect_err("the platform whitelist stays conservative"); + // Which fixed variant fires first is pass-order dependent (the + // nested-allOf drop runs before the keyword whitelist), so pin the + // set of acceptable conservative drops, not one of them. assert!( - matches!(error, KimiParameterSchemaError::InvalidSchemaNode), - "the anyOf branch's variant is InvalidSchemaNode: {error:?}" + matches!( + error, + KimiParameterSchemaError::UnsupportedKeyword + | KimiParameterSchemaError::UnsupportedNestedAllOf + ), + "the platform route rejects the first out-of-whitelist shape: {error:?}" ); - // The anyOf check is branch-order dependent: the terminator stops - // at the first terminating branch, so a terminating branch ahead - // of the offending one lets the tool ship — the repaired schema is - // instance-set-equivalent (the empty `items` was a no-op - // constraint), which is the desired outcome, but it means the - // fail-closed guarantee for anyOf holds only when the offending - // branch is reached. - let mut schema = json!({ + let mut all_of_only = json!({ "type": "object", "properties": { - "a": { - "anyOf": [ - {"type": "string"}, - {"type": "array", "items": {}, "minItems": 1} + "target": { + "type": "object", + "allOf": [ + { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"] + }, + { + "type": "object", + "properties": {"mode": {"type": "string"}} + } ] } - }, - "required": ["a"] + } + }); + // Two branches: a single-branch allOf is collapsed by the shared + // semantics-preserving pass before normalization ever sees it. + let error = sanitize_for_kimi_parameters(&mut all_of_only, KimiParameterRoute::Platform) + .expect_err("nested allOf keeps failing closed on the platform route"); + assert!( + matches!(error, KimiParameterSchemaError::UnsupportedNestedAllOf), + "the platform route keeps the nested-allOf drop: {error:?}" + ); + + // And a plain schema keeps sanitizing identically on both routes. + let mut plain = json!({ + "type": "object", + "properties": {"path": {"type": "string"}, "label": {"const": "fixed"}}, + "required": ["path"] }); - sanitize_for_kimi_parameters(&mut schema) - .expect("a terminating branch ahead of the offending one ships the tool"); + let mut expected = plain.clone(); + sanitize_for_kimi_parameters(&mut plain, KimiParameterRoute::Platform).unwrap(); + sanitize_for_kimi_parameters(&mut expected, KimiParameterRoute::KimiCode).unwrap(); assert_eq!( - schema["properties"]["a"]["anyOf"][1], - json!({"type": "array", "minItems": 1}), - "the shipped branch is the repaired, instance-set-equivalent form" + plain, expected, + "the relaxed whitelist must not change the shape of conservative documents" ); + } + + #[test] + fn kimi_code_route_keeps_core_mfjs_rules_strict() { + // The relaxations widen the whitelist only; every core fail-closed + // rule stays in force on the Kimi Code route too. + let mut schema = json!({ + "type": "object", + "properties": {"a": {"type": "array", "items": true}}, + "required": ["a"] + }); + let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::KimiCode) + .expect_err("boolean items still fail closed"); + assert!(matches!(error, KimiParameterSchemaError::InvalidSchemaNode)); let mut schema = json!({ "type": "object", - "$defs": {"a": {"type": "array", "items": {}, "minItems": 1}}, - "properties": {"x": {"$ref": "#/$defs/a"}}, - "required": ["x"] + "properties": {"choice": {"anyOf": [{"type": "string"}, {}]}} }); - let error = sanitize_for_kimi_parameters(&mut schema) - .expect_err("minItems >= 1 cannot terminate at a checked $ref target"); - assert!( - matches!(error, KimiParameterSchemaError::InvalidSchemaNode), - "the $ref target's variant is InvalidSchemaNode: {error:?}" - ); + let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::KimiCode) + .expect_err("an empty anyOf branch still fails closed"); + assert!(matches!(error, KimiParameterSchemaError::MissingType)); + + let mut schema = json!({ + "type": "object", + "properties": { + "price": {"type": "string", "exclusiveMinimum": 0} + } + }); + let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::KimiCode) + .expect_err("an exclusive bound on a string type still fails closed"); + assert!(matches!( + error, + KimiParameterSchemaError::InvalidKeywordValue + )); + + let mut schema = json!({ + "type": "object", + "properties": { + "price": { + "type": "number", + "exclusiveMinimum": 100, + "maximum": 50 + } + } + }); + let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::KimiCode) + .expect_err("an inverted bound pair still fails closed"); + assert!(matches!(error, KimiParameterSchemaError::InvalidRange)); } #[test] @@ -3138,7 +3551,7 @@ mod kimi_tests { "choice": {"anyOf": [{"type": "string"}, {}]} } }); - let error = sanitize_for_kimi_parameters(&mut schema) + let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) .expect_err("an empty anyOf branch fails closed"); assert!( matches!(error, KimiParameterSchemaError::MissingType), @@ -3151,7 +3564,7 @@ mod kimi_tests { "choice": {"anyOf": [{"type": "string"}, {"items": {"items": {}}}]} } }); - let error = sanitize_for_kimi_parameters(&mut schema) + let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) .expect_err("a branch the nested repair empties to {} fails closed"); assert!( matches!(error, KimiParameterSchemaError::MissingType), @@ -3163,7 +3576,7 @@ mod kimi_tests { "properties": {"a": {"type": "string"}}, "$defs": {"x": {}} }); - let error = sanitize_for_kimi_parameters(&mut schema) + let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) .expect_err("an empty $defs entry fails closed"); assert!( matches!(error, KimiParameterSchemaError::MissingType), @@ -3175,8 +3588,8 @@ mod kimi_tests { "properties": {"a": {"type": "array", "items": true}}, "required": ["a"] }); - let error = - sanitize_for_kimi_parameters(&mut schema).expect_err("boolean items fail closed"); + let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) + .expect_err("boolean items fail closed"); assert!( matches!(error, KimiParameterSchemaError::InvalidSchemaNode), "boolean items drops with InvalidSchemaNode: {error:?}" @@ -3187,8 +3600,8 @@ mod kimi_tests { "properties": {"a": {"type": "array", "items": [{"type": "string"}]}}, "required": ["a"] }); - let error = - sanitize_for_kimi_parameters(&mut schema).expect_err("tuple-form items fail closed"); + let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) + .expect_err("tuple-form items fail closed"); assert!( matches!(error, KimiParameterSchemaError::InvalidSchemaNode), "tuple items drops with InvalidSchemaNode: {error:?}" @@ -3241,7 +3654,8 @@ mod kimi_tests { for mut schema in fixtures { let original = schema.clone(); - let error = sanitize_for_kimi_parameters(&mut schema).unwrap_err(); + let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) + .unwrap_err(); assert_eq!(error, KimiParameterSchemaError::NonTerminatingReference); for sentinel in ["private-root", "private-node", "private-to"] { assert!(!error.to_string().contains(sentinel)); @@ -3267,7 +3681,7 @@ mod kimi_tests { } } }); - sanitize_for_kimi_parameters(&mut optional).unwrap(); + sanitize_for_kimi_parameters(&mut optional, KimiParameterRoute::Platform).unwrap(); let mut nullable = json!({ "type": "object", @@ -3295,7 +3709,7 @@ mod kimi_tests { } } }); - sanitize_for_kimi_parameters(&mut nullable).unwrap(); + sanitize_for_kimi_parameters(&mut nullable, KimiParameterRoute::Platform).unwrap(); } #[test] @@ -3311,7 +3725,8 @@ mod kimi_tests { } }); assert_eq!( - sanitize_for_kimi_parameters(&mut too_many_branches).unwrap_err(), + sanitize_for_kimi_parameters(&mut too_many_branches, KimiParameterRoute::Platform) + .unwrap_err(), KimiParameterSchemaError::ResourceLimitExceeded ); @@ -3335,7 +3750,8 @@ mod kimi_tests { } }); assert_eq!( - sanitize_for_kimi_parameters(&mut too_many_properties).unwrap_err(), + sanitize_for_kimi_parameters(&mut too_many_properties, KimiParameterRoute::Platform) + .unwrap_err(), KimiParameterSchemaError::ResourceLimitExceeded ); @@ -3350,7 +3766,8 @@ mod kimi_tests { } }); assert_eq!( - sanitize_for_kimi_parameters(&mut too_many_enum_values).unwrap_err(), + sanitize_for_kimi_parameters(&mut too_many_enum_values, KimiParameterRoute::Platform) + .unwrap_err(), KimiParameterSchemaError::ResourceLimitExceeded ); } @@ -3367,7 +3784,7 @@ mod kimi_tests { }); } assert_eq!( - sanitize_for_kimi_parameters(&mut too_deep).unwrap_err(), + sanitize_for_kimi_parameters(&mut too_deep, KimiParameterRoute::Platform).unwrap_err(), KimiParameterSchemaError::ResourceLimitExceeded ); @@ -3383,7 +3800,8 @@ mod kimi_tests { } }); assert_eq!( - sanitize_for_kimi_parameters(&mut too_much_enum_text).unwrap_err(), + sanitize_for_kimi_parameters(&mut too_much_enum_text, KimiParameterRoute::Platform) + .unwrap_err(), KimiParameterSchemaError::ResourceLimitExceeded ); } @@ -3400,7 +3818,7 @@ mod kimi_tests { "empty": {"type": "null", "default": null} } }); - sanitize_for_kimi_parameters(&mut valid).unwrap(); + sanitize_for_kimi_parameters(&mut valid, KimiParameterRoute::Platform).unwrap(); for (mut invalid, expected) in [ ( @@ -3438,7 +3856,8 @@ mod kimi_tests { ), ] { let original = invalid.clone(); - let error = sanitize_for_kimi_parameters(&mut invalid).unwrap_err(); + let error = sanitize_for_kimi_parameters(&mut invalid, KimiParameterRoute::Platform) + .unwrap_err(); assert_eq!(error, expected); assert!(!error.to_string().contains("private-default")); assert_eq!(invalid, original); @@ -3480,7 +3899,8 @@ mod kimi_tests { }), ] { assert_eq!( - sanitize_for_kimi_parameters(&mut schema).unwrap_err(), + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) + .unwrap_err(), KimiParameterSchemaError::InvalidRange ); } @@ -3500,7 +3920,7 @@ mod kimi_tests { "description": "File arguments" }); - sanitize_for_kimi_parameters(&mut schema).unwrap(); + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); assert_eq!(schema["type"], "object"); assert_eq!(schema["properties"]["path"]["type"], "string"); @@ -3519,7 +3939,8 @@ mod kimi_tests { }); let original = schema.clone(); - let error = sanitize_for_kimi_parameters(&mut schema).unwrap_err(); + let error = + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap_err(); assert_eq!(error, KimiParameterSchemaError::UnresolvedRootReference); assert!(!error.to_string().contains("private-schema-name-9217")); @@ -3558,7 +3979,8 @@ mod kimi_tests { fn kimi_degrades_dependent_keywords_to_the_flat_schema() { let mut schema = action_discriminated_schema(); - let note = sanitize_for_kimi_parameters(&mut schema).expect("must not refuse the schema"); + let note = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) + .expect("must not refuse the schema"); assert!(!contains_key_anywhere(&schema, "dependentSchemas")); assert!(!contains_key_anywhere(&schema, "dependentRequired")); @@ -3567,7 +3989,8 @@ mod kimi_tests { assert!(properties.contains_key(name), "{name} left the flat schema"); } assert_eq!(schema["required"], json!(["action"])); - validate_mfjs_parameters(&schema).expect("degraded schema must validate"); + validate_mfjs_parameters(&schema, KimiParameterRoute::Platform) + .expect("degraded schema must validate"); let note = note.expect("dropping a requirement must be reported to the model"); assert_eq!( @@ -3585,7 +4008,8 @@ mod kimi_tests { "required": ["path"] }); - let note = sanitize_for_kimi_parameters(&mut schema).expect("plain schema"); + let note = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) + .expect("plain schema"); assert_eq!( note, None, @@ -3610,7 +4034,8 @@ mod kimi_tests { } }); - let note = sanitize_for_kimi_parameters(&mut schema).expect("nested composition"); + let note = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) + .expect("nested composition"); assert!(!contains_key_anywhere(&schema, "dependentRequired")); assert!( @@ -3619,7 +4044,8 @@ mod kimi_tests { .expect("nested properties") .contains_key("path") ); - validate_mfjs_parameters(&schema).expect("degraded schema must validate"); + validate_mfjs_parameters(&schema, KimiParameterRoute::Platform) + .expect("degraded schema must validate"); assert!(note.is_some(), "nested drop must still be reported"); } @@ -3632,7 +4058,8 @@ mod kimi_tests { "dependentSchemas": ["invalid"] }); - let note = sanitize_for_kimi_parameters(&mut schema).expect("degraded schema"); + let note = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) + .expect("degraded schema"); assert!(note.is_some(), "every dropped dependency must be reported"); assert!(!contains_key_anywhere(&schema, "dependentRequired")); @@ -3649,7 +4076,8 @@ mod kimi_tests { }); let original = schema.clone(); - let error = sanitize_for_kimi_parameters(&mut schema).unwrap_err(); + let error = + sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap_err(); assert_eq!(error, KimiParameterSchemaError::ReferencedRootMustBeObject); assert!(!error.to_string().contains("private-scalar-name-4831")); diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 6c89e4085c..87c124a456 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -6072,8 +6072,11 @@ fn provider_schema_sanitizers_preserve_the_closed_fleet_role_enum() { // Moonshot/Kimi must retain the supported field vocabulary while dropping // the one root dependency keyword MFJS cannot represent. let mut kimi = agent_schema.clone(); - schema_sanitize::sanitize_for_kimi_parameters(&mut kimi) - .expect("agent schema must stay Kimi-compatible"); + schema_sanitize::sanitize_for_kimi_parameters( + &mut kimi, + schema_sanitize::KimiParameterRoute::KimiCode, + ) + .expect("agent schema must stay Kimi-compatible"); assert_eq!( kimi["properties"]["type"]["enum"], expected, "kimi sanitize must not erase or widen the role enum" @@ -6201,8 +6204,11 @@ fn agent_tool_schema_rejects_empty_input_across_provider_forms() { forms.push(("responses", responses)); let mut kimi = agent_schema; - schema_sanitize::sanitize_for_kimi_parameters(&mut kimi) - .expect("agent schema must stay Kimi-compatible"); + schema_sanitize::sanitize_for_kimi_parameters( + &mut kimi, + schema_sanitize::KimiParameterRoute::KimiCode, + ) + .expect("agent schema must stay Kimi-compatible"); forms.push(("kimi", kimi)); let empty = json!({}); From 959ff20703a6d187aa06963b3e929670d0f1ca50 Mon Sep 17 00:00:00 2001 From: asto Date: Thu, 5 Nov 2026 02:44:13 +0800 Subject: [PATCH 07/20] fix(tui): flatten Moonshot root unions again per the object-root rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reinstate the strict wire contract for Moonshot function parameters: a root oneOf/anyOf/allOf is flattened into a single type:object schema, as before 96fa83929. The Tool Use documentation requires "The root of `parameters` must be an `object`" (the MFJS specification lists anyOf as an applicator without authorizing it at a parameters root), and the new ruling is that the documents govern the wire shape — a 2026-11-04 live probe observing 200 for a root union is an observation, and per policy never a shipping basis against the documents. When the documents conflict, the strict face wins. Everything else from the 2026-11-04 batch is untouched: the nested oneOf-to-anyOf conversion, the type pushdown into anyOf branches, const-to-enum, nullable-to-anyOf, the empty-items repair, and the items-absence termination port. The union tests return to the baseline expectations (kimi_parameters_add_type_to_oneof_root pins the flatten; kimi_chat_flattens_root_union_of_object_branches pins it end to end on the Kimi Code wire), replacing the four union-passthrough tests. Red-green: kimi_parameters_add_type_to_oneof_root and kimi_chat_flattens_root_union_of_object_branches failed against the union-passthrough implementation (root union kept on the wire) and pass after the revert; 71 schema_sanitize tests, 156 moonshot/kimi-matching tests, and the 2684-test client:: tools:: gate green (one unrelated pre-existing flake, tools::file::read_file_byte_truncation_, green standalone twice). Signed-off-by: asto --- crates/tui/src/client/chat.rs | 72 +++--- crates/tui/src/tools/schema_sanitize.rs | 283 ++++-------------------- 2 files changed, 74 insertions(+), 281 deletions(-) diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 43806e64b6..13eb4117cc 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -1107,13 +1107,9 @@ fn mirror_minimax_reasoning_details_for_body(body: &mut Value, provider: ApiProv /// before transport. The tool name is safe to log — it is already visible in /// the UI — while the error's `Display` deliberately carries no schema values. /// -/// The route selects the keyword whitelist: the exact Kimi Code endpoint -/// (`moonshot_base_url_is_exact_kimi_code`, the China-only predicate) admits -/// the keywords its 2026-11-04 live probe verified, while platform endpoints -/// keep the conservative MFJS whitelist. This predicate is deliberately -/// stricter than the fixed-sampling strip (`kimi_code_sampling_route`), whose -/// overseas `.ai` host has no probe evidence of its own and therefore no -/// whitelist relaxation either. +/// The route selects the keyword whitelist (see [`KimiParameterRoute`]); the +/// per-tool drop plus its projection warning stay the final safety net on +/// every route. /// /// Returns the names of the dropped tools, in catalog order. fn sanitize_moonshot_chat_tools( @@ -1243,12 +1239,12 @@ pub(crate) fn build_chat_wire_body( .iter() .map(|tool| tool_to_chat_for_base_url(tool, base_url)) .collect(); - // Moonshot function parameters must end at a plain object root or at - // the MFJS-legal root union of object branches (walle specification; - // 2026-11-04 live probe). Flatten remaining root composition, - // preserve valid nested anyOf, and drop only the tools whose - // parameters cannot pass MFJS validation so one incompatible tool - // never sinks the whole request. + // Moonshot function parameters must end at a plain object root (the + // Tool Use documentation; a root union is flattened on the strict + // face of the conflicting documents). Flatten remaining root + // composition, preserve valid nested anyOf, and drop only the tools + // whose parameters cannot pass MFJS validation so one incompatible + // tool never sinks the whole request. if matches!(provider, crate::config::ApiProvider::Moonshot) { let route = if moonshot_base_url_is_exact_kimi_code(base_url) { crate::tools::schema_sanitize::KimiParameterRoute::KimiCode @@ -8616,15 +8612,16 @@ mod image_block_wire_tests { ); } - /// Moonshot's MFJS specification lists `anyOf` as a first-class - /// applicator, and a 2026-11-04 live probe of the Kimi Code endpoint - /// returned 200 for a root union of object branches with per-branch - /// `required` groups honored. The Moonshot lane must keep such a union — - /// with each branch's own required set — instead of folding it into one - /// merged object whose single required group starved k3's constrained - /// decoding into emitting only the required fields. + /// The Moonshot Tool Use documentation requires "The root of `parameters` + /// must be an `object`" (https://platform.kimi.ai/docs/api/tool-use, + /// fetched 2026-11-05). The MFJS specification lists `anyOf` as an + /// applicator but does not authorize it at a parameters root; on the + /// conflicting documents the strict face wins, so a root union of object + /// branches — even though a 2026-11-04 live probe observed the endpoint + /// returning 200 for one (probe facts are observations only and never a + /// shipping basis) — is flattened into one merged object on the wire. #[test] - fn kimi_chat_keeps_root_union_of_object_branches() { + fn kimi_chat_flattens_root_union_of_object_branches() { let union_schema = serde_json::json!({ "anyOf": [ { @@ -8689,33 +8686,28 @@ mod image_block_wire_tests { assert!( body.omitted_tool_names.is_empty(), - "the union root must not be dropped: {:?}", + "the flattenable union must not be dropped: {:?}", body.omitted_tool_names ); let parameters = &body.body["tools"][0]["function"]["parameters"]; - let branches = parameters["anyOf"] - .as_array() - .expect("root anyOf must survive"); - assert_eq!(branches.len(), 2, "both object variants must survive"); - assert_eq!( - branches[0]["required"], - serde_json::json!(["kind", "outline"]) - ); assert_eq!( - branches[1]["required"], - serde_json::json!(["kind", "content"]) + parameters["type"], "object", + "the wire root must be a plain object schema: {parameters}" ); assert!( - branches.iter().all(|branch| branch["type"] == "object"), - "MFJS requires the object type inside every branch: {parameters}" - ); - assert!( - parameters.get("type").is_none(), - "type may not sit beside the union keyword: {parameters}" + parameters.get("anyOf").is_none(), + "no root union may survive on the wire: {parameters}" ); + // The flatten merges the branch properties into one object; the + // per-branch required groups are gone, which is exactly the + // constraint loss the root composition note documents. + let properties = parameters["properties"].as_object().unwrap(); + for name in ["kind", "outline", "content"] { + assert!(properties.contains_key(name), "{name} left the merge"); + } assert!( - parameters.get("properties").is_none(), - "branch properties must not be merged into the root: {parameters}" + parameters.get("required").is_none(), + "no required set survives a union of disagreeing branches: {parameters}" ); } diff --git a/crates/tui/src/tools/schema_sanitize.rs b/crates/tui/src/tools/schema_sanitize.rs index 92e5584ccd..bb735a08e8 100644 --- a/crates/tui/src/tools/schema_sanitize.rs +++ b/crates/tui/src/tools/schema_sanitize.rs @@ -89,51 +89,6 @@ fn is_object_branch(branch: &Value) -> bool { branch.get("type").and_then(Value::as_str) == Some("object") } -/// Whether the schema root is a single `anyOf`/`oneOf` union whose every -/// branch declares `type: "object"`, beside only keywords that survive as -/// legal union siblings after the Kimi compatibility passes (`type` is pushed -/// down into the branches; annotations and root-only `$defs`/`$id` are -/// permitted). Returns the union's keyword so a root `oneOf` can be promoted -/// to the MFJS-documented `anyOf`. -/// -/// The Moonshot MFJS specification -/// () -/// admits `anyOf` as a first-class applicator: `type` must live inside the -/// branches, and every branch must be an object schema. A live probe of -/// on 2026-11-04 returned 200 for such root -/// unions with per-branch `required` groups honored, so they no longer need -/// the semantics-losing root flatten (which merged the branches' required -/// sets and left k3's constrained decoding to emit only the single merged -/// group). Anything else — a union with a non-object branch, both union -/// keywords at once, or an unrepresented keyword beside the union — returns -/// `None` and keeps the protective flatten. -fn kimi_root_object_union(schema: &Value) -> Option<(&'static str, &Vec)> { - let obj = schema.as_object()?; - if obj.contains_key("anyOf") && obj.contains_key("oneOf") { - // Two independent unions at the root would have to survive together; - // MFJS documents only `anyOf`, so the flatten keeps the safe merge. - return None; - } - let union_key: &'static str = if obj.contains_key("anyOf") { - "anyOf" - } else if obj.contains_key("oneOf") { - "oneOf" - } else { - return None; - }; - let branches = obj.get(union_key)?.as_array()?; - if branches.is_empty() || branches.iter().any(|branch| !is_object_branch(branch)) { - return None; - } - for key in obj.keys() { - match key.as_str() { - "anyOf" | "oneOf" | "type" | "title" | "description" | "$defs" | "$id" => {} - _ => return None, - } - } - Some((union_key, branches)) -} - /// Sanitize a schema for OpenAI Responses function tools. /// /// The Responses API requires the top-level `parameters` schema to be an object @@ -1357,14 +1312,7 @@ impl KimiParameterRoute { /// Normalize a complete Kimi / Moonshot `function.parameters` object. /// /// Function parameters have an additional MFJS constraint: the root must end -/// as a plain `type: "object"` schema or as the MFJS-legal object union — a -/// single `anyOf` whose every branch declares `type: "object"` (a root -/// `oneOf` of object branches is promoted to `anyOf`). The union shape is -/// backed by the walle MFJS specification, which lists `anyOf` as a -/// first-class applicator, and by a 2026-11-04 live probe of -/// (200, per-branch `required` honored); -/// keeping the branches preserves their per-branch required groups, which the -/// former flatten erased. Every other root composition is flattened using the +/// as a plain `type: "object"` schema. Root composition is flattened using the /// same compatibility pass as Responses and xAI, while supported nested /// `anyOf` branches remain nested. Internal root `$ref` values are resolved /// and inlined before normalization so we never manufacture the invalid @@ -1404,30 +1352,21 @@ fn sanitize_kimi_parameters_candidate( inline_internal_kimi_root_ref(parameters)?; - // A root union of explicit object branches is MFJS-legal since the walle - // specification listed `anyOf` as a first-class applicator (live probe - // 2026-11-04: 200 with per-branch `required` honored), so it passes - // through with only semantics-preserving normalization. A root `oneOf` is - // promoted to the documented `anyOf` first. Anything else — a union with - // a non-object branch (apply_patch's bare required-groups among them), - // root allOf/enum/not, or an unrepresented keyword beside the union — - // keeps the flatten, and retains its dropped required-group contract as a - // prompt note for the model. + // A function schema's root cannot carry MFJS composition because the + // Tool Use documentation requires an object root ("The root of + // `parameters` must be an `object`", + // https://platform.kimi.ai/docs/api/tool-use, fetched 2026-11-05). The + // MFJS specification lists `anyOf` as an applicator without authorizing + // it at a parameters root, so on the conflicting documents the strict + // face wins: every root union is flattened, and the flatten retains the + // dropped required-group contract as a prompt note for the model. (The + // 2026-11-04 live probe of the coding endpoint accepted a root union with + // 200 — an observation, and per policy never a shipping basis against the + // documents.) // MFJS has no conditional keywords. The shared compatibility pass drops // them with a bounded description note instead of sending a shape the // validator would reject (which would drop the whole tool). - let constraint_note = if kimi_root_object_union(parameters).is_some() { - if let Some(root) = parameters.as_object_mut() - && let Some(one_of) = root.remove("oneOf") - { - root.insert("anyOf".to_string(), one_of); - } - let dependent_note = drop_dependent_keywords(parameters); - sanitize(parameters); - dependent_note - } else { - sanitize_for_responses(parameters) - }; + let constraint_note = sanitize_for_responses(parameters); // Restore nullable unions collapsed by the registry's provider-neutral // sanitizer, translate MFJS-safe scalar const values, and normalize nested @@ -1455,12 +1394,12 @@ fn sanitize_kimi_parameters_candidate( let Some(root) = parameters.as_object() else { return Err(KimiParameterSchemaError::RootMustBeObject); }; - let plain_object_root = root.get("type").and_then(Value::as_str) == Some("object") - && !root.contains_key("anyOf") - && !root.contains_key("oneOf") - && !root.contains_key("allOf") - && !root.contains_key("$ref"); - if !plain_object_root && kimi_root_object_union(parameters).is_none() { + if root.get("type").and_then(Value::as_str) != Some("object") + || root.contains_key("anyOf") + || root.contains_key("oneOf") + || root.contains_key("allOf") + || root.contains_key("$ref") + { return Err(KimiParameterSchemaError::RootMustBeObject); } @@ -1732,17 +1671,12 @@ pub fn validate_mfjs_parameters( let root = parameters .as_object() .ok_or(KimiParameterSchemaError::RootMustBeObject)?; - // The root is either a plain `type: "object"` schema or the MFJS-legal - // object union (`anyOf` of explicit object branches, promoted from - // `oneOf` by the sanitizer); anything else stays rejected. A `type` - // beside the union is not part of either shape — the sibling check inside - // `validate_schema` rejects it with InvalidKeywordValue. - let plain_object_root = root.get("type").and_then(Value::as_str) == Some("object") - && !root.contains_key("anyOf") - && !root.contains_key("oneOf") - && !root.contains_key("allOf") - && !root.contains_key("$ref"); - if !plain_object_root && kimi_root_object_union(parameters).is_none() { + if root.get("type").and_then(Value::as_str) != Some("object") + || root.contains_key("anyOf") + || root.contains_key("oneOf") + || root.contains_key("allOf") + || root.contains_key("$ref") + { return Err(KimiParameterSchemaError::RootMustBeObject); } if serde_json::to_vec(parameters) @@ -2596,12 +2530,14 @@ mod kimi_tests { assert!(schema["properties"].get("type").is_none()); } - // Function parameters must end as a plain object root, or — since the - // walle specification listed anyOf as an applicator and the 2026-11-04 - // probe confirmed it live — as a root union of explicit object branches - // (see kimi_parameters_passes_root_anyof_of_object_branches_through). - // Every other composition stays flattened; the tests below pin those - // protective shapes. + // Function parameters must end as a plain object root. Composition stays + // available only in valid nested anyOf positions: the Moonshot Tool Use + // documentation requires "The root of `parameters` must be an `object`" + // (https://platform.kimi.ai/docs/api/tool-use, fetched 2026-11-05), and + // the MFJS specification lists `anyOf` as an applicator without + // authorizing it at a parameters root — on the conflicting documents the + // strict face wins, so every root union is flattened. The tests below pin + // those protective shapes. #[test] fn kimi_parameters_add_type_to_anyof_root() { @@ -2631,64 +2567,7 @@ mod kimi_tests { } #[test] - fn kimi_parameters_passes_root_anyof_of_object_branches_through() { - // The 2026-06 incident shape (root anyOf, per-branch required groups) - // used to be flattened into one merged object, erasing every branch's - // own required set. The walle MFJS specification - // (https://github.com/MoonshotAI/walle/blob/main/docs/mfjs-spec.zh.md) - // now lists `anyOf` as a first-class applicator — branches must be - // objects and `type` must live inside them — and a live probe of - // https://api.kimi.com/coding/v1 on 2026-11-04 returned 200 with - // per-branch required groups honored, so the union must survive. - let mut schema = json!({ - "anyOf": [ - { - "type": "object", - "properties": { - "kind": {"type": "string"}, - "outline": {"type": "string"} - }, - "required": ["kind", "outline"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "kind": {"type": "string"}, - "content": {"type": "string"} - }, - "required": ["kind", "content"], - "additionalProperties": false - } - ] - }); - - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); - - let branches = schema["anyOf"].as_array().expect("root union survives"); - assert_eq!(branches.len(), 2); - assert_eq!( - branches[0]["required"], - json!(["kind", "outline"]), - "each branch keeps its own required group" - ); - assert_eq!(branches[1]["required"], json!(["kind", "content"])); - assert!( - branches.iter().all(|branch| branch["type"] == "object"), - "MFJS requires the object type inside every branch: {schema}" - ); - assert!( - !schema.as_object().unwrap().contains_key("type"), - "type may not sit beside the union keyword on the wire" - ); - validate_mfjs_parameters(&schema, KimiParameterRoute::Platform).unwrap(); - } - - #[test] - fn kimi_parameters_promotes_root_oneof_of_object_branches_to_anyof() { - // oneOf stays outside the MFJS specification, so a root oneOf of - // object branches is promoted to the documented anyOf union instead - // of being flattened. + fn kimi_parameters_add_type_to_oneof_root() { let mut schema = json!({ "oneOf": [ { @@ -2706,53 +2585,18 @@ mod kimi_tests { sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); + assert_eq!(schema["type"], "object"); assert!(schema.get("oneOf").is_none()); - let branches = schema["anyOf"].as_array().expect("promoted union"); - assert_eq!(branches.len(), 2); - assert_eq!(branches[0]["required"], json!(["id"])); - assert_eq!(branches[1]["required"], json!(["name"])); - validate_mfjs_parameters(&schema, KimiParameterRoute::Platform).unwrap(); - } - - #[test] - fn kimi_parameters_pushes_root_type_into_object_union_branches() { - // MFJS forbids `type` beside the union keyword; the compatibility - // pushdown moves it into every branch so the sent shape matches the - // specification (the endpoint also accepted the parent-side type, so - // the pushdown is a conformance nicety, not a 400 guard). - let mut schema = json!({ - "type": "object", - "anyOf": [ - { - "type": "object", - "properties": {"path": {"type": "string"}}, - "required": ["path"] - }, - { - "type": "object", - "properties": {"url": {"type": "string"}}, - "required": ["url"] - } - ] - }); - - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); - - assert!( - !schema.as_object().unwrap().contains_key("type"), - "root type must be pushed down: {schema}" - ); - assert!(!schema.as_object().unwrap().contains_key("properties")); - let branches = schema["anyOf"].as_array().unwrap(); - assert!(branches.iter().all(|branch| branch["type"] == "object")); + assert_eq!(schema["properties"]["id"]["type"], "integer"); + assert_eq!(schema["properties"]["name"]["type"], "string"); validate_mfjs_parameters(&schema, KimiParameterRoute::Platform).unwrap(); } #[test] fn kimi_parameters_still_flattens_root_union_with_non_object_branch() { // A branch without an explicit object type keeps the protective - // flatten: the MFJS applicator rule only admits object branches, and - // a union with e.g. a null branch has no documented wire shape. + // flatten: the Tool Use documentation requires an object root, and a + // union with e.g. a null branch has no documented wire shape. let mut schema = json!({ "anyOf": [ { @@ -2771,9 +2615,9 @@ mod kimi_tests { #[test] fn kimi_parameters_still_flattens_root_union_with_extra_root_keywords() { - // Keywords beside the union that MFJS would reject as union siblings - // (here: root `properties`) keep the flatten instead of shipping a - // shape the validator must fail closed. + // Keywords beside the union that would be lost by a union passthrough + // (here: root `properties`) keep the flatten — a parameters root must + // be an object schema, so every root union merges into one. let mut schema = json!({ "anyOf": [ { @@ -2794,49 +2638,6 @@ mod kimi_tests { assert!(schema.get("anyOf").is_none()); } - #[test] - fn mfjs_validator_accepts_union_root_but_rejects_union_siblings() { - let union_root = json!({ - "anyOf": [ - { - "type": "object", - "properties": {"path": {"type": "string"}}, - "required": ["path"] - }, - { - "type": "object", - "properties": {"url": {"type": "string"}}, - "required": ["url"] - } - ] - }); - validate_mfjs_parameters(&union_root, KimiParameterRoute::Platform).unwrap(); - - let mut typed_union = union_root.clone(); - typed_union - .as_object_mut() - .unwrap() - .insert("type".to_string(), json!("object")); - assert_eq!( - validate_mfjs_parameters(&typed_union, KimiParameterRoute::Platform).unwrap_err(), - KimiParameterSchemaError::InvalidKeywordValue, - "type may not sit beside the union keyword" - ); - - let mixed_branch_union = json!({ - "anyOf": [ - {"type": "object", "properties": {"a": {"type": "string"}}}, - {"type": "string"} - ] - }); - assert_eq!( - validate_mfjs_parameters(&mixed_branch_union, KimiParameterRoute::Platform) - .unwrap_err(), - KimiParameterSchemaError::RootMustBeObject, - "a union root with a non-object branch is not an object schema" - ); - } - #[test] fn kimi_parameters_flattens_actual_apply_patch_root_and_returns_constraint_note() { let mut schema = ApplyPatchTool.input_schema(); From c18938ddf7d87a536be4a4f51b02bba8908a74e2 Mon Sep 17 00:00:00 2001 From: asto Date: Thu, 5 Nov 2026 03:11:50 +0800 Subject: [PATCH 08/20] fix(tui): hold the Moonshot tool schema to the MFJS documented set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the probe-driven route tiers with one pipeline governed by the MFJS specification itself (https://github.com/MoonshotAI/walle/blob/main/docs/mfjs-spec.zh.md, fetched 2026-11-05). The governing ruling: the wire must follow the published documents strictly — a live probe may only ever tighten it, never widen it, and the 2026-11-04 observation that the coding endpoint accepted pattern/format/$schema/prefixItems/exclusive bounds/nested allOf is an observation only. The specification's closing clause leaves every unlisted keyword unsupported, so the KimiParameterRoute split (Kimi Code tier admitting probe-observed keywords, platform tier conservative) is deleted: every Moonshot route — api.moonshot.cn/ai, api.kimi.com and api.kimi.ai coding endpoints — serves the same document and gets the same wire contract. Two doc-conformance passes are added ahead of validation, each removing only what the document cannot express and whose removal never drops the tool: - strip_mfjs_advisory_keywords removes the annotation keywords (title, $comment, $id) and the unlisted bound keywords (minLength, maxLength, minimum, maximum, minItems, maxItems) — semantics-lossless or soft-constraint-losing simplifications. - strip_mfjs_unsupported_enum_values removes an enum whose values are not homogeneous float/int/str scalars ("枚举值我们只支持基础类型中的 float, int 和 str……不支持混合类型"); an enum-only node has no type to fall back to and still fails closed. The validator whitelist shrinks to the specification's supported set exactly: $ref (internal, root-ref pre-inlined), root-only $defs, anyOf, properties, additionalProperties, items, type, enum, required, default, description. The walle source (SupportedKeywords, ValidateLevelDefault==ultra) additionally carries min-max/title/$id, but the published specification does not — on the conflicting documents the specification text governs. Everything unlisted and unstrippable (pattern, format, $schema, prefixItems, exclusive bounds, nested allOf) keeps the baseline whole-tool drop with its ToolProjectionWarning. Kept: nested oneOf-to-anyOf, type pushdown, const-to-enum, nullable-to-anyOf, the empty-items repair, the items-absence termination port, the route-scoped fixed-sampling strip, the xAI union passthrough, and the MiniMax gateway gating. Malformed or inverted bounds no longer drop anything: the strip removes the keyword before any value is validated (kimi_parameters_strips_bounds_instead_of_rejecting_them). Red-green: probe_observed_keywords_fail_closed_on_every_route, advisory_keywords_are_stripped_not_shipped, unsupported_enum_values_are_stripped_and_tool_survives, probe_observed_keywords_never_reach_the_wire, advisory_keywords_are_stripped_from_the_wire, and overseas_kimi_ai_endpoint_keeps_the_document_parameter_whitelist failed against the route-tier implementation (probe keywords shipped on the Kimi Code tier, advisory keywords shipped everywhere) and pass after the shrink; shipped_pptx_slides_schema_passes_moonshot_validation_on_the_ document_set now pins the single pipeline plus a wire-keyword matrix (every keyword on the normalized pptx document must sit inside the documented set, layout/title names being property names). 71 schema_sanitize tests, 156 kimi/moonshot/sampling tests, and the 2684-test client:: tools:: gate green (the documented pre-existing forkguard_model_operation_auxiliary_retry_keys flake green standalone). Signed-off-by: asto --- crates/tui/src/client.rs | 19 +- crates/tui/src/client/chat.rs | 241 ++-- crates/tui/src/tools/schema_sanitize.rs | 1341 +++++++++++------------ crates/tui/src/tools/subagent/tests.rs | 14 +- 4 files changed, 753 insertions(+), 862 deletions(-) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 986086c96d..25941c7c2c 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -6348,11 +6348,7 @@ mod tests { parameters["properties"]["query"].get("nullable").is_none(), "{parameters}" ); - crate::tools::schema_sanitize::validate_mfjs_parameters( - parameters, - crate::tools::schema_sanitize::KimiParameterRoute::KimiCode, - ) - .unwrap(); + crate::tools::schema_sanitize::validate_mfjs_parameters(parameters).unwrap(); } async fn assert_kimi_code_captures_exact_general_child_catalog() { @@ -6380,11 +6376,8 @@ mod tests { // production errors remain fixed and non-secret. for tool in &tools { let mut parameters = tool.input_schema.clone(); - crate::tools::schema_sanitize::sanitize_for_kimi_parameters( - &mut parameters, - crate::tools::schema_sanitize::KimiParameterRoute::KimiCode, - ) - .unwrap_or_else(|error| panic!("General child tool {}: {error}", tool.name)); + crate::tools::schema_sanitize::sanitize_for_kimi_parameters(&mut parameters) + .unwrap_or_else(|error| panic!("General child tool {}: {error}", tool.name)); } let mut request = k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("low"), false); @@ -6424,11 +6417,7 @@ mod tests { tool["function"]["name"] ); } - crate::tools::schema_sanitize::validate_mfjs_parameters( - parameters, - crate::tools::schema_sanitize::KimiParameterRoute::KimiCode, - ) - .unwrap(); + crate::tools::schema_sanitize::validate_mfjs_parameters(parameters).unwrap(); } } diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 13eb4117cc..3fe6cf6121 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -18,7 +18,7 @@ use crate::config::{ TOGETHER_INKLING_MODEL, is_exact_direct_moonshot_k3_route, is_exact_kimi_code_k3_route, is_exact_xai_grok_4_6_route, is_exact_zai_chat_route, is_exact_zai_forced_thinking_route, is_exact_zai_tiered_effort_route, minimax_m3_route_uses_max_completion_tokens, - moonshot_base_url_is_exact_kimi_code, wire_model_for_provider_route, + wire_model_for_provider_route, }; // The bounded response-header wait (`stream_open_timeout`) and its env @@ -957,11 +957,6 @@ fn apply_direct_moonshot_k3_fixed_sampling( /// `is_exact_kimi_code_route` itself belongs to in-flight PR #137, so the /// sampling strip carries this route-scoped twin here; it must stay in sync /// with those semantics. -/// -/// Deliberately narrower consumers keep the stricter China-only predicate -/// (`moonshot_base_url_is_exact_kimi_code`): the `KimiParameterRoute` -/// whitelist selection is probe-backed only for `.com`, and the replay / -/// billing contracts aligned with it advance through #137. #[must_use] fn kimi_code_sampling_route(base_url: &str) -> bool { const KIMI_CODE_ENDPOINTS: [(&str, &str); 2] = @@ -1107,15 +1102,13 @@ fn mirror_minimax_reasoning_details_for_body(body: &mut Value, provider: ApiProv /// before transport. The tool name is safe to log — it is already visible in /// the UI — while the error's `Display` deliberately carries no schema values. /// -/// The route selects the keyword whitelist (see [`KimiParameterRoute`]); the -/// per-tool drop plus its projection warning stay the final safety net on -/// every route. +/// One pipeline serves every Moonshot route: the wire contract is the MFJS +/// specification's supported set, uniform across the platform and Kimi Code +/// endpoints. The per-tool drop plus its projection warning stays the final +/// safety net. /// /// Returns the names of the dropped tools, in catalog order. -fn sanitize_moonshot_chat_tools( - chat_tools: &mut Vec, - route: crate::tools::schema_sanitize::KimiParameterRoute, -) -> Vec { +fn sanitize_moonshot_chat_tools(chat_tools: &mut Vec) -> Vec { let mut dropped = Vec::new(); chat_tools.retain_mut(|tool| { let Some(function) = tool @@ -1128,7 +1121,7 @@ fn sanitize_moonshot_chat_tools( let Some(parameters) = function.get_mut("parameters") else { return true; }; - match crate::tools::schema_sanitize::sanitize_for_kimi_parameters(parameters, route) { + match crate::tools::schema_sanitize::sanitize_for_kimi_parameters(parameters) { Ok(note) => { if let Some(note) = note { let description = function @@ -1246,12 +1239,7 @@ pub(crate) fn build_chat_wire_body( // whose parameters cannot pass MFJS validation so one incompatible // tool never sinks the whole request. if matches!(provider, crate::config::ApiProvider::Moonshot) { - let route = if moonshot_base_url_is_exact_kimi_code(base_url) { - crate::tools::schema_sanitize::KimiParameterRoute::KimiCode - } else { - crate::tools::schema_sanitize::KimiParameterRoute::Platform - }; - omitted_tool_names = sanitize_moonshot_chat_tools(&mut chat_tools, route); + omitted_tool_names = sanitize_moonshot_chat_tools(&mut chat_tools); } // xAI rejects a parameters root that is not a plain object schema // (e.g. apply_patch's root `oneOf` required-groups) with a 400. @@ -6743,13 +6731,12 @@ mod alias_thinking_detection_tests { } #[test] - fn overseas_kimi_ai_endpoint_keeps_the_conservative_parameter_whitelist() { - // Widening the sampling strip to the overseas host must NOT widen - // the KimiParameterRoute selection: the relaxed whitelist tier is - // backed by a live probe of the China endpoint only, so `pattern` (a - // KimiCode-route keyword) still fails the platform whitelist on - // `.ai` and the tool degrades per tool. Sampling shaping and route - // selection are deliberately split. + fn overseas_kimi_ai_endpoint_keeps_the_document_parameter_whitelist() { + // Widening the sampling strip to the overseas host must NOT widen the + // tool-schema contract: both Kimi Code hosts serve the same MFJS + // specification, so a keyword the specification does not list + // (`pattern`) degrades the tool on both, while the sampling strip + // (document-backed: "omit them from requests") covers both hosts. let patterned_tool = crate::models::Tool { tool_type: None, name: "patterned".to_string(), @@ -6770,50 +6757,34 @@ mod alias_thinking_detection_tests { let mut request = sampling_probe_request("k3.1-pro"); request.tools = Some(vec![patterned_tool]); - let overseas = crate::client::chat::build_chat_wire_body( - &request, - ApiProvider::Moonshot, + for base_url in [ "https://api.kimi.ai/coding/v1", - false, - ) - .expect("overseas Kimi Code wire body"); - assert_eq!( - overseas.omitted_tool_names, - vec!["patterned".to_string()], - "the conservative platform whitelist must stay in force on `.ai`" - ); - assert!( - overseas.body.get("tools").is_none(), - "the dropped tool must not ship: {}", - overseas.body - ); - // The sampling strip, by contrast, does cover the overseas route. - assert!( - overseas.body.get("temperature").is_none() && overseas.body.get("top_p").is_none(), - "fixed sampling must still strip on `.ai`: {}", - overseas.body - ); - - // The China endpoint keeps the relaxed tier it was probed for: the - // same tool ships with its `pattern` intact. - let china = crate::client::chat::build_chat_wire_body( - &request, - ApiProvider::Moonshot, crate::config::DEFAULT_KIMI_CODE_BASE_URL, - false, - ) - .expect("China Kimi Code wire body"); - assert!( - china.omitted_tool_names.is_empty(), - "the probe-verified keyword must ship on the probed route: {:?}", - china.omitted_tool_names - ); - assert_eq!( - china.body["tools"][0]["function"]["parameters"]["properties"]["label"]["pattern"], - json!("^[a-z]+$"), - "pattern must survive on the China coding route: {}", - china.body - ); + ] { + let body = crate::client::chat::build_chat_wire_body( + &request, + ApiProvider::Moonshot, + base_url, + false, + ) + .expect("Kimi Code wire body"); + assert_eq!( + body.omitted_tool_names, + vec!["patterned".to_string()], + "the document-absent keyword drops the tool on {base_url}" + ); + assert!( + body.body.get("tools").is_none(), + "the dropped tool must not ship on {base_url}: {}", + body.body + ); + // The sampling strip covers the route regardless. + assert!( + body.body.get("temperature").is_none() && body.body.get("top_p").is_none(), + "fixed sampling must still strip on {base_url}: {}", + body.body + ); + } } #[test] @@ -8744,120 +8715,90 @@ mod image_block_wire_tests { } } - /// The 2026-11-04 live probe verified pattern, format, $schema, - /// prefixItems, exclusiveMinimum, and nested allOf end to end on the - /// exact Kimi Code endpoint: the wire must keep them. + /// Keywords the 2026-11-04 live probe merely observed the endpoint + /// accepting (pattern, format, $schema, prefixItems, exclusiveMinimum, + /// exclusiveMaximum, nested allOf) sit outside the MFJS specification's + /// supported set. Probe facts are observations only and never a shipping + /// basis, so the tool carrying them degrades with a projection warning on + /// every Moonshot route — one pipeline, one wire contract. #[test] - fn kimi_code_route_keeps_probe_verified_keywords_on_wire() { + fn probe_observed_keywords_never_reach_the_wire() { let schema = serde_json::json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "label": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$", "format": "uri" - }, - "price": {"type": "number", "exclusiveMinimum": 0}, - "row": { - "type": "array", - "prefixItems": [{"type": "string"}], - "minItems": 1 - }, - "target": { - "type": "object", - "allOf": [ - { - "type": "object", - "properties": {"path": {"type": "string"}}, - "required": ["path"] - }, - { - "type": "object", - "properties": {"mode": {"type": "string"}} - } - ] } }, "required": ["label"] }); let request = single_tool_request("probe_tool", schema); - let body = build_chat_wire_body( - &request, - ApiProvider::Moonshot, + for base_url in [ crate::config::DEFAULT_KIMI_CODE_BASE_URL, - false, - ) - .expect("Kimi Code wire body"); + crate::config::DEFAULT_MOONSHOT_BASE_URL, + ] { + let body = build_chat_wire_body(&request, ApiProvider::Moonshot, base_url, false) + .expect("Moonshot wire body"); - assert!( - body.omitted_tool_names.is_empty(), - "probe-verified keywords must not drop the tool: {:?}", - body.omitted_tool_names - ); - let parameters = &body.body["tools"][0]["function"]["parameters"]; - assert_eq!( - parameters["properties"]["label"]["pattern"], - serde_json::json!("^[a-z][a-z0-9-]*$") - ); - assert_eq!( - parameters["properties"]["label"]["format"], - serde_json::json!("uri") - ); - assert_eq!( - parameters["properties"]["price"]["exclusiveMinimum"], - serde_json::json!(0) - ); - assert_eq!( - parameters["properties"]["row"]["prefixItems"], - serde_json::json!([{"type": "string"}]) - ); - assert_eq!( - parameters["properties"]["target"]["allOf"] - .as_array() - .map(Vec::len), - Some(2), - "nested allOf must survive on the Kimi Code route: {parameters}" - ); + assert_eq!( + body.omitted_tool_names, + vec!["probe_tool".to_string()], + "the probe-observed keyword drops the tool on {base_url}" + ); + assert!( + body.body.get("tools").is_none(), + "a fully dropped catalog must omit the tools key on {base_url}: {}", + body.body + ); + } } - /// Platform endpoints keep the conservative MFJS whitelist: the same - /// probe schema is not verifiable there, an unsupported keyword risks a - /// whole-request 400, and the per-tool drop plus the projection warning - /// stay the final safety net. + /// Advisory keywords the MFJS specification does not list (title, + /// minLength, ...) are stripped in place — a semantics-lossless or + /// soft-constraint-losing simplification — so the tool still ships and + /// the wire carries only the document's supported set. #[test] - fn moonshot_platform_route_keeps_the_conservative_whitelist() { + fn advisory_keywords_are_stripped_from_the_wire() { let schema = serde_json::json!({ + "title": "Annotated tool", "type": "object", "properties": { - "label": { - "type": "string", - "pattern": "^[a-z][a-z0-9-]*$", - "format": "uri" - } + "label": {"type": "string", "title": "Label", "minLength": 1, "maxLength": 40}, + "price": {"type": "number", "minimum": 0, "maximum": 100} }, "required": ["label"] }); - let request = single_tool_request("probe_tool", schema); + let request = single_tool_request("annotated_tool", schema); let body = build_chat_wire_body( &request, ApiProvider::Moonshot, - crate::config::DEFAULT_MOONSHOT_BASE_URL, + crate::config::DEFAULT_KIMI_CODE_BASE_URL, false, ) - .expect("platform wire body"); + .expect("Kimi Code wire body"); - assert_eq!( - body.omitted_tool_names, - vec!["probe_tool".to_string()], - "the unsupported keyword drops only this tool on the platform route" - ); assert!( - body.body.get("tools").is_none(), - "a fully dropped catalog must omit the tools key: {}", - body.body + body.omitted_tool_names.is_empty(), + "advisory keywords must not drop the tool: {:?}", + body.omitted_tool_names + ); + let parameters = &body.body["tools"][0]["function"]["parameters"]; + for keyword in ["title", "minLength", "maxLength", "minimum", "maximum"] { + assert!( + !serde_json::to_string(parameters) + .unwrap() + .contains(&format!("\"{keyword}\"")), + "{keyword} must not reach the wire: {parameters}" + ); + } + assert_eq!(parameters["type"], "object"); + assert_eq!( + parameters["properties"]["label"]["type"], "string", + "the supported vocabulary survives the strip: {parameters}" ); } } diff --git a/crates/tui/src/tools/schema_sanitize.rs b/crates/tui/src/tools/schema_sanitize.rs index bb735a08e8..918849b566 100644 --- a/crates/tui/src/tools/schema_sanitize.rs +++ b/crates/tui/src/tools/schema_sanitize.rs @@ -85,10 +85,6 @@ pub fn sanitize_for_xai_parameters(parameters: &mut Value) -> Option { sanitize_for_responses(parameters) } -fn is_object_branch(branch: &Value) -> bool { - branch.get("type").and_then(Value::as_str) == Some("object") -} - /// Sanitize a schema for OpenAI Responses function tools. /// /// The Responses API requires the top-level `parameters` schema to be an object @@ -404,10 +400,9 @@ fn root_composition_constraint_note(obj: &Map) -> Option /// /// Status note (2026-11-04): that whole-request failure mode no longer /// exists — the request builder degrades per tool (drop + projection -/// warning), and the keyword whitelist itself is now route-aware -/// ([`KimiParameterRoute`]). These two conditional keywords remain outside -/// both whitelists (the walle MFJS specification has no conditional -/// keywords), so the strip stays the semantics-preserving way to keep such a +/// warning). These two conditional keywords remain outside +/// the MFJS supported keyword set (the walle MFJS specification has no +/// conditional keywords), so the strip stays the semantics-preserving way to keep such a /// tool shippable. fn drop_dependent_keywords(schema: &mut Value) -> Option { strip_dependent_keywords(schema).then(|| { @@ -1146,16 +1141,6 @@ pub fn sanitize_for_kimi(schema: &mut serde_json::Value) { } } } - // The Kimi Code route admits nested allOf and prefixItems; walk their - // branch schemas so unions buried inside them still get the pushdown. - // On the platform route neither keyword survives normalization. - for composition_key in ["allOf", "prefixItems"] { - if let Some(branches) = obj.get_mut(composition_key).and_then(Value::as_array_mut) { - for branch in branches { - sanitize_for_kimi(branch); - } - } - } // If this object has `type` + `anyOf`/`oneOf`, push `type` into // each item and remove it from the parent. Otherwise leave it alone. @@ -1185,18 +1170,6 @@ pub fn sanitize_for_kimi(schema: &mut serde_json::Value) { obj.remove("properties"); } } - - // The same injection lands on nested allOf nodes (Kimi Code route), - // where the composition grammar admits no `properties` sibling; the - // empty map is the injection's own no-op, so remove it the same way. - if obj.contains_key("allOf") - && obj - .get("properties") - .and_then(Value::as_object) - .is_some_and(Map::is_empty) - { - obj.remove("properties"); - } } } @@ -1242,71 +1215,6 @@ pub enum KimiParameterSchemaError { NonTerminatingReference, #[error("Moonshot function parameters contain an invalid MFJS default")] InvalidDefault, - #[error("Moonshot function parameters contain an invalid MFJS range")] - InvalidRange, -} - -/// Which Moonshot wire contract a parameters schema is being normalized for. -/// -/// The MFJS keyword whitelist is route-sensitive: the Kimi Code membership -/// endpoint accepts a set of extra keywords that the platform endpoints have -/// never been probed for, so platform traffic keeps the conservative -/// whitelist while the exact coding route gets the live-verified additions. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum KimiParameterRoute { - /// Moonshot platform endpoints (`api.moonshot.cn` / `api.moonshot.ai`) and - /// every non-exact Moonshot route: the conservative MFJS keyword - /// whitelist, unchanged. The walle specification does not list the extra - /// keywords for these endpoints and no live probe backs them, so an - /// unsupported keyword there still risks a whole-request 400 — the - /// asymmetry that keeps this route strict. - /// - /// "Conservative whitelist, unchanged" refers to the MFJS keyword - /// allow-list, not to this route's whole normalization behavior. Relative - /// to the pre-2026-11 baseline (`f1eb10efc`) the platform route carries - /// two intentional behavior changes, both shipped for the Kimi Code route - /// first and applied here because they are route-independent in code: - /// - /// 1. Root-level object unions pass through: an `anyOf` (or a promoted - /// `oneOf`) whose every branch declares `type: "object"` is no longer - /// flattened into one merged object, so per-branch `required` groups - /// survive. Backed by the walle MFJS specification listing `anyOf` as - /// a first-class applicator (plus the 2026-11-04 live probe of the - /// coding endpoint). - /// 2. Array termination without `items`: an array required to be - /// non-empty (`minItems >= 1`) with `items` absent now counts as - /// terminating instead of failing with `InvalidSchemaNode`, so the - /// tool ships rather than being excluded. Ported from upstream - /// codewhale-hq `e3e563204` (#7015 review follow-up). - /// - /// Everything else is baseline-consistent: the keyword whitelist itself, - /// nested `allOf` failing closed here (open only on [`KimiParameterRoute::KimiCode`]), - /// and per-tool degradation for schemas that cannot pass validation. - Platform, - /// The exact first-party Kimi Code endpoint - /// (`https://api.kimi.com/coding/v1`): the conservative whitelist plus the - /// keywords a 2026-11-04 live probe verified end to end (200, model - /// honors the constraints): `pattern`, `format`, `$schema`, `prefixItems`, - /// `exclusiveMinimum`, `exclusiveMaximum`, and nested `allOf` (accepted by - /// the server; the documented position is that unstated keywords are - /// simply not guaranteed stable, so their semantics stay the schema - /// author's responsibility). - /// - /// Route selection deliberately keeps matching only the China host - /// (`api.kimi.com`) even though the sampling strip also covers the - /// overseas `api.kimi.ai/coding/v1` spelling: the whitelist relaxation is - /// backed by a probe of the China endpoint only, and `.ai` has no probe - /// evidence of its own, so it stays on [`KimiParameterRoute::Platform`]'s - /// conservative tier until an independent probe or the predicate work in - /// in-flight PR #137 widens `is_exact_kimi_code_route`. - KimiCode, -} - -impl KimiParameterRoute { - /// Whether this route admits the live-verified keyword extensions. - fn is_relaxed(self) -> bool { - matches!(self, KimiParameterRoute::KimiCode) - } } /// Normalize a complete Kimi / Moonshot `function.parameters` object. @@ -1318,6 +1226,15 @@ impl KimiParameterRoute { /// and inlined before normalization so we never manufacture the invalid /// `type + allOf($ref)` shape rejected by MFJS. /// +/// One pipeline serves every Moonshot route (api.moonshot.cn / ai platform, +/// api.kimi.com and api.kimi.ai coding endpoints): the documents behind those +/// routes all point at the same MFJS specification, so the wire contract is +/// uniform. Keywords the specification does not list are either stripped +/// (annotation and unlisted bound keywords, whose removal is a +/// semantics-lossless or soft-constraint-losing simplification) or drop the +/// whole tool with a projection warning (composition and pattern keywords +/// with no simplification equivalent). +/// /// Unsupported, unresolved, cyclic, and non-object root references fail /// closed with a non-secret diagnostic instead of being sent to Moonshot. /// @@ -1325,20 +1242,18 @@ impl KimiParameterRoute { /// pub fn sanitize_for_kimi_parameters( parameters: &mut Value, - route: KimiParameterRoute, ) -> Result, KimiParameterSchemaError> { // Work on a clone so a rejected schema remains byte-for-byte unchanged for // callers that retain the catalog and retry against another provider. let mut candidate = parameters.clone(); - let constraint_note = sanitize_kimi_parameters_candidate(&mut candidate, route)?; - validate_mfjs_parameters(&candidate, route)?; + let constraint_note = sanitize_kimi_parameters_candidate(&mut candidate)?; + validate_mfjs_parameters(&candidate)?; *parameters = candidate; Ok(constraint_note) } fn sanitize_kimi_parameters_candidate( parameters: &mut Value, - route: KimiParameterRoute, ) -> Result, KimiParameterSchemaError> { let Some(root) = parameters.as_object() else { return Err(KimiParameterSchemaError::RootMustBeObject); @@ -1372,17 +1287,23 @@ fn sanitize_kimi_parameters_candidate( // sanitizer, translate MFJS-safe scalar const values, and normalize nested // composition. This changes model-facing schema guidance only; each tool // keeps responsibility for validating its own arguments. Nested allOf - // fails closed on the platform route; the Kimi Code route passes it - // through (live-verified accepted 2026-11-04, semantics the schema - // author's responsibility). - normalize_kimi_compatibility(parameters, true, route)?; + // fails closed. + normalize_kimi_compatibility(parameters, true)?; // MFJS requires `type` to live inside each anyOf branch, never alongside - // the union keyword. On the pass-through shape this moves a parent-side - // type into the branches (matching the specification); the flatten path - // is composition-free at this point, so it only adjusts nested unions. + // the union keyword. The root is composition-free at this point, so this + // only adjusts valid nested unions. sanitize_for_kimi(parameters); + // Keywords the MFJS specification does not list are removed before + // validation when removal is a semantics-lossless or + // soft-constraint-losing simplification, so the tool still ships and the + // wire carries only the document's supported set. Anything without such + // an equivalent (pattern, format, $schema, prefixItems, nested allOf, + // exclusive bounds) is left for the validator to fail closed. + strip_mfjs_advisory_keywords(parameters); + strip_mfjs_unsupported_enum_values(parameters); + // JSON Schema reads an empty `items` schema as "items are unconstrained", // which is exactly the MFJS-legal shape with the keyword absent. MFJS // itself has no empty schema node and fails the whole tool with @@ -1406,25 +1327,143 @@ fn sanitize_kimi_parameters_candidate( Ok(constraint_note) } +/// Remove the advisory keywords the MFJS specification does not support from +/// every schema node of a Kimi parameters document. +/// +/// The specification's supported keyword set is closed ("若官方规范中的某些 +/// 概念、关键字或特性在本文档中未被提及,则表示当前 MFJS 尚未提供官方支持", +/// , +/// fetched 2026-11-05), and its unsupported-features list names the +/// annotation group (`title`, `$comment`) explicitly. The bound keywords +/// (`minLength`, `maxLength`, `minimum`, `maximum`, `minItems`, `maxItems`) +/// and the document identity keyword `$id` are likewise unlisted. Stripping +/// them is either semantics-lossless (annotations) or loses only soft +/// constraints (bounds the model is free to violate without breaking the +/// call), so removal keeps the tool shippable instead of degrading it — +/// strictly more conservative on the wire than shipping an unlisted keyword. +/// Every other unlisted keyword (pattern, format, $schema, prefixItems, +/// exclusive bounds, nested allOf) has no such simplification equivalent and +/// is left for `validate_mfjs_parameters` to fail closed. +/// +/// The traversal matches `remove_empty_mfjs_items_schemas`: property values, +/// `$defs` entries, `anyOf` branches, object-form `additionalProperties`, and +/// `items` are the schema-value positions. `oneOf`/`allOf` need no visit: +/// this runs after `normalize_kimi_compatibility`, which has already +/// converted or rejected every such node. +fn strip_mfjs_advisory_keywords(value: &mut Value) { + const ADVISORY_KEYWORDS: [&str; 9] = [ + "title", + "$comment", + "$id", + "minLength", + "maxLength", + "minimum", + "maximum", + "minItems", + "maxItems", + ]; + let Some(obj) = value.as_object_mut() else { + return; + }; + if let Some(properties) = obj.get_mut("properties").and_then(Value::as_object_mut) { + for child in properties.values_mut() { + strip_mfjs_advisory_keywords(child); + } + } + if let Some(definitions) = obj.get_mut("$defs").and_then(Value::as_object_mut) { + for child in definitions.values_mut() { + strip_mfjs_advisory_keywords(child); + } + } + if let Some(branches) = obj.get_mut("anyOf").and_then(Value::as_array_mut) { + for branch in branches.iter_mut() { + strip_mfjs_advisory_keywords(branch); + } + } + if let Some(additional) = obj.get_mut("additionalProperties") + && additional.is_object() + { + strip_mfjs_advisory_keywords(additional); + } + if let Some(items) = obj.get_mut("items") { + strip_mfjs_advisory_keywords(items); + } + for keyword in ADVISORY_KEYWORDS { + obj.shift_remove(keyword); + } +} + +/// Remove every `enum` whose values the MFJS specification does not support. +/// +/// "枚举值我们只支持基础类型中的 float, int 和 str,其它基础类型以及 object +/// 和 array 暂不支持,并且不支持混合类型 (List 中的每个枚举值的类型必须相同, +/// 如不可以 str + int)" (, fetched 2026-11-05). An enum carrying boolean, null, +/// object, or array values, or mixing value kinds, is replaced by no +/// constraint at all — a node without `enum` accepts any value of its type, +/// so removal only loses the enumeration guidance and the tool stays +/// shippable instead of being degraded wholesale. Homogeneous +/// float/int/str enums pass through untouched, and an empty or non-array +/// `enum` is left for the validator to fail closed (a malformed keyword is a +/// schema bug, not a simplification opportunity). +fn strip_mfjs_unsupported_enum_values(value: &mut Value) { + let Some(obj) = value.as_object_mut() else { + return; + }; + if let Some(properties) = obj.get_mut("properties").and_then(Value::as_object_mut) { + for child in properties.values_mut() { + strip_mfjs_unsupported_enum_values(child); + } + } + if let Some(definitions) = obj.get_mut("$defs").and_then(Value::as_object_mut) { + for child in definitions.values_mut() { + strip_mfjs_unsupported_enum_values(child); + } + } + if let Some(branches) = obj.get_mut("anyOf").and_then(Value::as_array_mut) { + for branch in branches.iter_mut() { + strip_mfjs_unsupported_enum_values(branch); + } + } + if let Some(additional) = obj.get_mut("additionalProperties") + && additional.is_object() + { + strip_mfjs_unsupported_enum_values(additional); + } + if let Some(items) = obj.get_mut("items") { + strip_mfjs_unsupported_enum_values(items); + } + if let Some(values) = obj.get("enum").and_then(Value::as_array) { + // `None` kinds (non-scalars) or differing kinds make the enum + // unsupported; an empty array stays for the validator to fail + // closed, per the doc comment above. + let kinds: Option> = values.iter().map(mfjs_literal_kind).collect(); + let homogeneous = kinds + .is_some_and(|kinds| !kinds.is_empty() && kinds.iter().all(|kind| kind == &kinds[0])); + if !homogeneous { + obj.shift_remove("enum"); + } + } +} + /// Drop every empty `items` schema node (`"items": {}`) from a Kimi /// parameters document. /// /// Empty-schema nodes sit exactly where schema values live: property values, -/// `items`, object-form `additionalProperties`, `anyOf` branches, `$defs` -/// entries, and — since the Kimi Code route admits nested `allOf` and -/// `prefixItems` — their branch schemas too. `properties` itself is a name -/// map, so its own emptiness is legal and only its values are visited. -/// `oneOf` needs no visit: this runs after `normalize_kimi_compatibility` has -/// converted every non-root `oneOf` to `anyOf`, and root composition was -/// already flattened before that. Schema values under the keywords MFJS still -/// rejects everywhere (`contains`, `patternProperties`, `not`, -/// `if`/`then`/`else`, `propertyNames`, `unevaluatedProperties`, -/// `unevaluatedItems`, tuple-form `items`) need no visit: the keyword itself -/// fails closed — `UnsupportedKeyword`, or `InvalidSchemaNode` for tuple-form -/// `items` — before a stranded empty `items` node inside one could matter. -/// `dependentSchemas` is the one exception in shape: -/// `strip_dependent_keywords` removes it (with its subtree) rather than -/// failing closed, so it cannot strand one either. +/// `items`, object-form `additionalProperties`, `anyOf` branches, and `$defs` +/// entries. `properties` itself is a name map, so its own emptiness is legal +/// and only its values are visited. `oneOf` needs no visit: this runs after +/// `normalize_kimi_compatibility` has converted every non-root `oneOf` to +/// `anyOf`, and root composition was already flattened before that. +/// Schema values under keywords MFJS rejects (`contains`, `prefixItems`, +/// `patternProperties`, `not`, `if`/`then`/`else`, `propertyNames`, +/// `unevaluatedProperties`, `unevaluatedItems`, nested `allOf`, tuple-form +/// `items`) need no visit: the keyword itself fails closed — +/// `UnsupportedKeyword`, `UnsupportedNestedAllOf` for a nested `allOf`, or +/// `InvalidSchemaNode` for tuple-form `items` — before a stranded empty +/// `items` node inside one could matter. `dependentSchemas` is the one +/// exception in shape: `strip_dependent_keywords` removes it (with its +/// subtree) rather than failing closed, so it cannot strand one either. /// /// Children are visited before the node's own `items` check so a nested /// repair can empty the parent (`"items": {"items": {}}` is vacuously true @@ -1451,16 +1490,6 @@ fn remove_empty_mfjs_items_schemas(value: &mut Value) { remove_empty_mfjs_items_schemas(branch); } } - // Branch schemas of the two composition keywords the Kimi Code route - // admits. On the platform route neither keyword survives normalization, - // so these visits see nothing there. - for composition_key in ["allOf", "prefixItems"] { - if let Some(branches) = obj.get_mut(composition_key).and_then(Value::as_array_mut) { - for branch in branches.iter_mut() { - remove_empty_mfjs_items_schemas(branch); - } - } - } if let Some(additional) = obj.get_mut("additionalProperties") && additional.is_object() { @@ -1528,20 +1557,17 @@ fn inline_internal_kimi_root_ref(parameters: &mut Value) -> Result<(), KimiParam fn normalize_kimi_compatibility( schema: &mut Value, is_root: bool, - route: KimiParameterRoute, ) -> Result<(), KimiParameterSchemaError> { let Some(obj) = schema.as_object_mut() else { return Err(KimiParameterSchemaError::InvalidSchemaNode); }; if !is_root { - if obj.contains_key("allOf") && !route.is_relaxed() { - // Platform endpoints have no live evidence for nested allOf, and - // the walle specification does not list the keyword anywhere, so - // the conservative drop stays. The Kimi Code route's 2026-11-04 - // probe verified the server accepts nested allOf (200); the - // documented position for unstated keywords is "not guaranteed - // stable", so the schema author owns its semantics. + if obj.contains_key("allOf") { + // The walle MFJS specification does not list the keyword anywhere, + // and unlike annotation or bound keywords its removal has no + // semantics-preserving equivalent, so the whole tool fails + // closed with a projection warning. return Err(KimiParameterSchemaError::UnsupportedNestedAllOf); } if let Some(one_of) = obj.remove("oneOf") { @@ -1602,14 +1628,11 @@ fn normalize_kimi_compatibility( Some(None) => return Err(KimiParameterSchemaError::InvalidNullable), } - normalize_kimi_child_schemas(schema, route)?; + normalize_kimi_child_schemas(schema)?; Ok(()) } -fn normalize_kimi_child_schemas( - schema: &mut Value, - route: KimiParameterRoute, -) -> Result<(), KimiParameterSchemaError> { +fn normalize_kimi_child_schemas(schema: &mut Value) -> Result<(), KimiParameterSchemaError> { let Some(obj) = schema.as_object_mut() else { return Err(KimiParameterSchemaError::InvalidSchemaNode); }; @@ -1617,33 +1640,22 @@ fn normalize_kimi_child_schemas( for map_key in ["properties", "$defs"] { if let Some(children) = obj.get_mut(map_key).and_then(Value::as_object_mut) { for child in children.values_mut() { - normalize_kimi_compatibility(child, false, route)?; + normalize_kimi_compatibility(child, false)?; } } } if let Some(items) = obj.get_mut("items") { - normalize_kimi_compatibility(items, false, route)?; + normalize_kimi_compatibility(items, false)?; } if let Some(additional) = obj.get_mut("additionalProperties") && additional.is_object() { - normalize_kimi_compatibility(additional, false, route)?; + normalize_kimi_compatibility(additional, false)?; } if let Some(branches) = obj.get_mut("anyOf").and_then(Value::as_array_mut) { for branch in branches { - normalize_kimi_compatibility(branch, false, route)?; - } - } - // The Kimi Code route admits nested allOf and prefixItems; normalize their - // branch schemas the same way. On the platform route a nested allOf never - // reaches here (it failed closed above) and prefixItems is rejected - // wholesale by the validator, so these visits see nothing there. - for composition_key in ["allOf", "prefixItems"] { - if let Some(branches) = obj.get_mut(composition_key).and_then(Value::as_array_mut) { - for branch in branches { - normalize_kimi_compatibility(branch, false, route)?; - } + normalize_kimi_compatibility(branch, false)?; } } Ok(()) @@ -1659,15 +1671,15 @@ fn is_mfjs_enum_literal(value: &Value) -> bool { /// runtime tools and may contain private names or values, so diagnostics must /// never echo a keyword, property, reference, or literal from the document. /// -/// `route` selects the keyword whitelist: the exact Kimi Code endpoint -/// additionally admits the keywords its 2026-11-04 live probe verified -/// (`pattern`, `format`, `$schema`, `prefixItems`, `exclusiveMinimum`, -/// `exclusiveMaximum`, nested `allOf`), while platform endpoints keep the -/// conservative MFJS whitelist. -pub fn validate_mfjs_parameters( - parameters: &Value, - route: KimiParameterRoute, -) -> Result<(), KimiParameterSchemaError> { +/// The keyword whitelist is the MFJS specification's supported set, exactly: +/// `$ref` (internal, already inlined at the root), root-only `$defs`, `anyOf`, +/// `properties`, `additionalProperties`, `items`, `type`, `enum`, `required`, +/// `default`, and `description`. The specification's own source +/// (walle `SupportedKeywords`) additionally accepts `title`, `$id`, and the +/// min/max bound keywords, but its published specification does not — on the +/// conflicting documents the specification text governs, so the narrower set +/// wins and the advisory keywords are stripped upstream in the sanitizer. +pub fn validate_mfjs_parameters(parameters: &Value) -> Result<(), KimiParameterSchemaError> { let root = parameters .as_object() .ok_or(KimiParameterSchemaError::RootMustBeObject)?; @@ -1686,7 +1698,7 @@ pub fn validate_mfjs_parameters( return Err(KimiParameterSchemaError::ResourceLimitExceeded); } - let mut state = MfjsValidationState::new(parameters, route); + let mut state = MfjsValidationState::new(parameters); state.validate_schema(parameters, true, false, 0, 0)?; let mut visiting_refs = HashSet::new(); @@ -1715,16 +1727,14 @@ const MFJS_MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; struct MfjsValidationState<'a> { document: &'a Value, - route: KimiParameterRoute, total_properties: usize, total_enum_values: usize, } impl<'a> MfjsValidationState<'a> { - fn new(document: &'a Value, route: KimiParameterRoute) -> Self { + fn new(document: &'a Value) -> Self { Self { document, - route, total_properties: 0, total_enum_values: 0, } @@ -1752,8 +1762,13 @@ impl<'a> MfjsValidationState<'a> { }; } + // The specification's supported keyword set, exactly. The walle + // source's `SupportedKeywords` additionally contains `title`, `$id`, + // and the min/max bound keywords, but the published specification + // does not list them; the document governs, so they sit outside this + // set (the sanitizer strips them upstream, so only a direct + // unsanitized call can reach this rejection). const ALLOWED_KEYWORDS: &[&str] = &[ - "$id", "$ref", "$defs", "anyOf", @@ -1763,64 +1778,22 @@ impl<'a> MfjsValidationState<'a> { "type", "enum", "required", - "maxLength", - "minLength", - "maximum", - "minimum", - "maxItems", - "minItems", - "title", - "description", "default", + "description", ]; - const RELAXED_KEYWORDS: &[&str] = &[ - // Kimi Code route only, live-verified 2026-11-04 against - // https://api.kimi.com/coding/v1 (200, model honored the - // constraints): string constraints, tuple items, exclusive - // numeric bounds, the document identity annotation, and nested - // allOf composition. The walle specification does not list these - // keywords, so unstated-keyword semantics stay the schema - // author's responsibility; platform endpoints keep the - // conservative whitelist. - "pattern", - "format", - "$schema", - "prefixItems", - "exclusiveMinimum", - "exclusiveMaximum", - "allOf", - ]; - let relaxed = self.route.is_relaxed(); - if obj.keys().any(|keyword| { - !ALLOWED_KEYWORDS.contains(&keyword.as_str()) - && (!relaxed || !RELAXED_KEYWORDS.contains(&keyword.as_str())) - }) { + if obj + .keys() + .any(|keyword| !ALLOWED_KEYWORDS.contains(&keyword.as_str())) + { return Err(KimiParameterSchemaError::UnsupportedKeyword); } - for annotation in ["title", "description"] { - if obj.get(annotation).is_some_and(|value| !value.is_string()) { - return Err(KimiParameterSchemaError::InvalidKeywordValue); - } - } - if obj.get("$id").is_some_and(|value| !value.is_string()) - || (!is_root && obj.contains_key("$id")) - { - return Err(KimiParameterSchemaError::InvalidKeywordValue); - } - if obj.get("$schema").is_some_and(|value| !value.is_string()) - || (!is_root && obj.contains_key("$schema")) + if obj + .get("description") + .is_some_and(|value| !value.is_string()) { return Err(KimiParameterSchemaError::InvalidKeywordValue); } - for string_keyword in ["pattern", "format"] { - if obj - .get(string_keyword) - .is_some_and(|value| !value.is_string()) - { - return Err(KimiParameterSchemaError::InvalidKeywordValue); - } - } if let Some(definitions) = obj.get("$defs") { if !is_root { @@ -1845,8 +1818,7 @@ impl<'a> MfjsValidationState<'a> { return Err(KimiParameterSchemaError::InvalidReference); } let allowed_ref_sibling = |key: &str| { - matches!(key, "$ref" | "title" | "description") - || (is_root && matches!(key, "$defs" | "$id")) + matches!(key, "$ref" | "description") || (is_root && matches!(key, "$defs")) }; if obj.keys().any(|key| !allowed_ref_sibling(key)) { return Err(KimiParameterSchemaError::InvalidReference); @@ -1860,8 +1832,7 @@ impl<'a> MfjsValidationState<'a> { .filter(|branches| !branches.is_empty() && branches.len() <= MFJS_MAX_ANY_OF_ITEMS) .ok_or(KimiParameterSchemaError::ResourceLimitExceeded)?; let allowed_union_sibling = |key: &str| { - matches!(key, "anyOf" | "title" | "description") - || (is_root && matches!(key, "$defs" | "$id")) + matches!(key, "anyOf" | "description") || (is_root && matches!(key, "$defs")) }; if obj.keys().any(|key| !allowed_union_sibling(key)) { return Err(KimiParameterSchemaError::InvalidKeywordValue); @@ -1872,34 +1843,6 @@ impl<'a> MfjsValidationState<'a> { return Ok(()); } - // Nested allOf on the Kimi Code route: same grammar slot as anyOf, - // plus a concrete `type` sibling (the endpoint accepted typed nodes - // live; anyOf forbids the sibling by specification, allOf is simply - // unlisted, so the merged-object reading stays available). Combined - // with anyOf the node is rejected by the sibling check above. - if let Some(all_of) = obj.get("allOf") { - let branches = all_of - .as_array() - .filter(|branches| !branches.is_empty() && branches.len() <= MFJS_MAX_ANY_OF_ITEMS) - .ok_or(KimiParameterSchemaError::ResourceLimitExceeded)?; - let allowed_composition_sibling = |key: &str| { - matches!(key, "allOf" | "title" | "description" | "type") - || (is_root && matches!(key, "$defs" | "$id" | "$schema")) - }; - if obj.keys().any(|key| !allowed_composition_sibling(key)) { - return Err(KimiParameterSchemaError::InvalidKeywordValue); - } - if let Some(schema_type) = obj.get("type").and_then(Value::as_str) - && !is_mfjs_concrete_type(schema_type) - { - return Err(KimiParameterSchemaError::InvalidKeywordValue); - } - for branch in branches { - self.validate_schema(branch, false, false, property_depth, structural_depth + 1)?; - } - return Ok(()); - } - let schema_type = obj .get("type") .and_then(Value::as_str) @@ -1907,7 +1850,7 @@ impl<'a> MfjsValidationState<'a> { .ok_or(KimiParameterSchemaError::MissingType)?; if obj .keys() - .any(|keyword| !mfjs_keyword_allowed_for_type(keyword, schema_type, is_root, relaxed)) + .any(|keyword| !mfjs_keyword_allowed_for_type(keyword, schema_type, is_root)) { return Err(KimiParameterSchemaError::InvalidKeywordValue); } @@ -1986,20 +1929,6 @@ impl<'a> MfjsValidationState<'a> { self.validate_schema(items, false, false, property_depth, structural_depth + 1)?; } - // Tuple items on the Kimi Code route: each prefix schema is validated - // like an items schema. The keyword only reaches this point for an - // array-typed node (see `mfjs_keyword_allowed_for_type`). - if let Some(prefix_items) = obj.get("prefixItems") { - let branches = prefix_items - .as_array() - .filter(|branches| branches.len() <= MFJS_MAX_ANY_OF_ITEMS) - .ok_or(KimiParameterSchemaError::ResourceLimitExceeded)?; - for branch in branches { - self.validate_schema(branch, false, false, property_depth, structural_depth + 1)?; - } - } - - validate_mfjs_bounds(obj, schema_type)?; Ok(()) } } @@ -2011,35 +1940,23 @@ fn is_mfjs_concrete_type(schema_type: &str) -> bool { ) } -fn mfjs_keyword_allowed_for_type( - keyword: &str, - schema_type: &str, - is_root: bool, - relaxed: bool, -) -> bool { - if is_root && matches!(keyword, "$defs" | "$id") { - return true; - } - if relaxed && is_root && keyword == "$schema" { +fn mfjs_keyword_allowed_for_type(keyword: &str, schema_type: &str, is_root: bool) -> bool { + // The specification's per-type keyword placement, exactly. `title`, the + // min/max bound keywords, and `$id` are not listed in the specification + // and no longer appear anywhere on the wire (the sanitizer strips the + // advisory ones); only a direct unsanitized validation call can see them + // here, and they fail closed like any other unlisted keyword. + if is_root && keyword == "$defs" { return true; } - if matches!(keyword, "type" | "title" | "description") { + if matches!(keyword, "type" | "description") { return true; } match schema_type { "object" => matches!(keyword, "properties" | "required" | "additionalProperties"), - "array" => { - matches!(keyword, "items" | "minItems" | "maxItems") - || (relaxed && keyword == "prefixItems") - } - "string" => { - matches!(keyword, "enum" | "default" | "minLength" | "maxLength") - || (relaxed && matches!(keyword, "pattern" | "format")) - } - "number" | "integer" => { - matches!(keyword, "enum" | "default" | "minimum" | "maximum") - || (relaxed && matches!(keyword, "exclusiveMinimum" | "exclusiveMaximum")) - } + "array" => keyword == "items", + "string" => matches!(keyword, "enum" | "default"), + "number" | "integer" => matches!(keyword, "enum" | "default"), "boolean" | "null" => keyword == "default", _ => false, } @@ -2130,72 +2047,6 @@ fn mfjs_integer_value(value: &Value) -> Option { .then_some(value) } -fn validate_mfjs_bounds( - obj: &Map, - schema_type: &str, -) -> Result<(), KimiParameterSchemaError> { - validate_mfjs_unsigned_range(obj, schema_type, "string", "minLength", "maxLength")?; - validate_mfjs_unsigned_range(obj, schema_type, "array", "minItems", "maxItems")?; - - // The exclusive bounds only reach this point on the Kimi Code route - // (whitelist-gated), but presence is the only gate needed here: on the - // platform route the keyword never survives the whitelist, so the check - // is a no-op there. - let lower_values = [obj.get("minimum"), obj.get("exclusiveMinimum")]; - let upper_values = [obj.get("maximum"), obj.get("exclusiveMaximum")]; - if lower_values.iter().all(Option::is_none) && upper_values.iter().all(Option::is_none) { - return Ok(()); - } - let parse_bound = |value: &Value| match schema_type { - "integer" => mfjs_integer_value(value), - "number" => value.as_number().and_then(serde_json::Number::as_f64), - _ => None, - }; - let parse_side = |values: &[Option<&Value>]| { - values - .iter() - .flatten() - .map(|value| parse_bound(value).ok_or(KimiParameterSchemaError::InvalidRange)) - .collect::, KimiParameterSchemaError>>() - }; - let lowers = parse_side(&lower_values)?; - let uppers = parse_side(&upper_values)?; - if lowers - .iter() - .any(|lower| uppers.iter().any(|upper| lower > upper)) - { - return Err(KimiParameterSchemaError::InvalidRange); - } - Ok(()) -} - -fn validate_mfjs_unsigned_range( - obj: &Map, - schema_type: &str, - expected_type: &str, - minimum_keyword: &str, - maximum_keyword: &str, -) -> Result<(), KimiParameterSchemaError> { - let minimum = obj.get(minimum_keyword); - let maximum = obj.get(maximum_keyword); - if minimum.is_none() && maximum.is_none() { - return Ok(()); - } - if schema_type != expected_type { - return Err(KimiParameterSchemaError::InvalidRange); - } - let minimum = minimum - .map(|value| value.as_u64().ok_or(KimiParameterSchemaError::InvalidRange)) - .transpose()?; - let maximum = maximum - .map(|value| value.as_u64().ok_or(KimiParameterSchemaError::InvalidRange)) - .transpose()?; - if minimum.zip(maximum).is_some_and(|(min, max)| min > max) { - return Err(KimiParameterSchemaError::InvalidRange); - } - Ok(()) -} - fn resolve_mfjs_reference<'a>(document: &'a Value, reference: &str) -> Option<&'a Value> { if reference == "#" { return Some(document); @@ -2245,18 +2096,6 @@ fn mfjs_schema_can_terminate( return Ok(false); } - // Nested allOf (Kimi Code route): a conjunct terminates only when every - // branch does. The keyword is whitelist-gated, so platform documents - // never reach this arm. - if let Some(branches) = obj.get("allOf").and_then(Value::as_array) { - for branch in branches { - if !mfjs_schema_can_terminate(branch, document, visiting_refs, structural_depth + 1)? { - return Ok(false); - } - } - return Ok(true); - } - match obj.get("type").and_then(Value::as_str) { Some("object") => { let Some(required) = obj.get("required").and_then(Value::as_array) else { @@ -2287,10 +2126,12 @@ fn mfjs_schema_can_terminate( Ok(true) } Some("array") => { - // An absent or non-integer `minItems` reads as 0. Non-integer - // values are unreachable here only because `validate_mfjs_bounds` - // rejects them (InvalidRange) on every node before the terminator - // runs — keep that call order if the walkers are reordered. + // `minItems` is outside the specification's supported set, so the + // sanitizer has already stripped it and an absent keyword reads + // as 0 — an empty array always satisfies, so the node terminates. + // A direct unsanitized validation call can still surface a + // carried `minItems` here; reading it as the lower bound stays + // the conservative reading. let min_items = obj.get("minItems").and_then(Value::as_u64).unwrap_or(0); if min_items == 0 { return Ok(true); @@ -2299,24 +2140,7 @@ fn mfjs_schema_can_terminate( // follow-up): MFJS allows `items` to be absent, and an // unconstrained array can always be satisfied, so absent `items` // counts as terminating instead of failing with - // InvalidSchemaNode. Tuple `prefixItems` entries still bind the - // first `minItems` elements, so those prefixes are checked when - // present. - if let Some(prefix_items) = obj.get("prefixItems").and_then(Value::as_array) { - let count = usize::try_from(min_items) - .unwrap_or(usize::MAX) - .min(prefix_items.len()); - for branch in &prefix_items[..count] { - if !mfjs_schema_can_terminate( - branch, - document, - visiting_refs, - structural_depth + 1, - )? { - return Ok(false); - } - } - } + // InvalidSchemaNode. let Some(items) = obj.get("items") else { return Ok(true); }; @@ -2510,7 +2334,7 @@ mod kimi_tests { #[test] fn kimi_parameters_add_type_to_empty_root() { let mut schema = json!({}); - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); + sanitize_for_kimi_parameters(&mut schema).unwrap(); assert_eq!(schema, json!({"type": "object", "properties": {}})); } @@ -2523,7 +2347,7 @@ mod kimi_tests { "required": ["path"] }); - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); + sanitize_for_kimi_parameters(&mut schema).unwrap(); assert_eq!(schema["type"], "object"); assert_eq!(schema["properties"]["path"]["type"], "string"); @@ -2547,7 +2371,7 @@ mod kimi_tests { {"type": "null"} ] }); - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); + sanitize_for_kimi_parameters(&mut schema).unwrap(); assert_eq!(schema["type"], "object"); assert!(schema.get("anyOf").is_none()); assert_eq!(schema["properties"]["path"]["type"], "string"); @@ -2560,7 +2384,7 @@ mod kimi_tests { {"type": "object", "properties": {"name": {"type": "string"}}} ] }); - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); + sanitize_for_kimi_parameters(&mut schema).unwrap(); assert_eq!(schema["type"], "object"); assert!(schema.get("allOf").is_none()); assert_eq!(schema["properties"]["name"]["type"], "string"); @@ -2583,13 +2407,13 @@ mod kimi_tests { ] }); - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); + sanitize_for_kimi_parameters(&mut schema).unwrap(); assert_eq!(schema["type"], "object"); assert!(schema.get("oneOf").is_none()); assert_eq!(schema["properties"]["id"]["type"], "integer"); assert_eq!(schema["properties"]["name"]["type"], "string"); - validate_mfjs_parameters(&schema, KimiParameterRoute::Platform).unwrap(); + validate_mfjs_parameters(&schema).unwrap(); } #[test] @@ -2607,7 +2431,7 @@ mod kimi_tests { {"type": "null"} ] }); - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); + sanitize_for_kimi_parameters(&mut schema).unwrap(); assert_eq!(schema["type"], "object"); assert!(schema.get("anyOf").is_none()); assert_eq!(schema["properties"]["path"]["type"], "string"); @@ -2633,7 +2457,7 @@ mod kimi_tests { ], "properties": {"legacy": {"type": "string"}} }); - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); + sanitize_for_kimi_parameters(&mut schema).unwrap(); assert_eq!(schema["type"], "object"); assert!(schema.get("anyOf").is_none()); } @@ -2642,7 +2466,7 @@ mod kimi_tests { fn kimi_parameters_flattens_actual_apply_patch_root_and_returns_constraint_note() { let mut schema = ApplyPatchTool.input_schema(); - let note = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); + let note = sanitize_for_kimi_parameters(&mut schema).unwrap(); assert_eq!(schema["type"], "object"); assert!(schema.get("oneOf").is_none()); @@ -2674,7 +2498,7 @@ mod kimi_tests { } }); - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); + sanitize_for_kimi_parameters(&mut schema).unwrap(); assert_eq!(schema["type"], "object"); let selector = &schema["properties"]["selector"]; @@ -2699,7 +2523,7 @@ mod kimi_tests { } }); - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); + sanitize_for_kimi_parameters(&mut schema).unwrap(); let selector = &schema["properties"]["selector"]; assert!(selector.get("oneOf").is_none()); @@ -2728,7 +2552,7 @@ mod kimi_tests { assert_eq!(schema["properties"]["query"]["nullable"], true); assert!(schema["properties"]["query"].get("anyOf").is_none()); - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); + sanitize_for_kimi_parameters(&mut schema).unwrap(); let query = &schema["properties"]["query"]; assert!(query.get("nullable").is_none(), "{query}"); @@ -2736,7 +2560,7 @@ mod kimi_tests { query["anyOf"], json!([{"type": "string"}, {"type": "null"}]) ); - validate_mfjs_parameters(&schema, KimiParameterRoute::Platform).unwrap(); + validate_mfjs_parameters(&schema).unwrap(); } #[test] @@ -2762,7 +2586,7 @@ mod kimi_tests { } }); - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); + sanitize_for_kimi_parameters(&mut schema).unwrap(); let kind = schema .pointer("/properties/envelope/properties/items/items/properties/kind") @@ -2781,8 +2605,7 @@ mod kimi_tests { }); let original = schema.clone(); - let error = - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap_err(); + let error = sanitize_for_kimi_parameters(&mut schema).unwrap_err(); assert_eq!(error, KimiParameterSchemaError::UnsupportedConstLiteral); assert!(!error.to_string().contains("private-toggle-8172")); @@ -2802,8 +2625,7 @@ mod kimi_tests { }); let original = schema.clone(); - let error = - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap_err(); + let error = sanitize_for_kimi_parameters(&mut schema).unwrap_err(); let diagnostic = error.to_string(); assert_eq!(error, KimiParameterSchemaError::UnsupportedKeyword); @@ -2842,8 +2664,7 @@ mod kimi_tests { ), ] { let original = schema.clone(); - let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) - .unwrap_err(); + let error = sanitize_for_kimi_parameters(&mut schema).unwrap_err(); assert_eq!(error, expected); for sentinel in sentinels { assert!(!error.to_string().contains(sentinel)); @@ -2863,7 +2684,7 @@ mod kimi_tests { } }); - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); + sanitize_for_kimi_parameters(&mut schema).unwrap(); assert_eq!(schema["properties"]["kind"]["type"], "string"); assert_eq!(schema["properties"]["kind"]["enum"], json!(["var_handle"])); @@ -2879,7 +2700,7 @@ mod kimi_tests { "type": "object", "additionalProperties": {} }); - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); + sanitize_for_kimi_parameters(&mut schema).unwrap(); assert_eq!(schema["additionalProperties"], json!({})); let mut invalid = json!({ @@ -2887,7 +2708,7 @@ mod kimi_tests { "properties": {"value": {}} }); assert_eq!( - sanitize_for_kimi_parameters(&mut invalid, KimiParameterRoute::Platform).unwrap_err(), + sanitize_for_kimi_parameters(&mut invalid).unwrap_err(), KimiParameterSchemaError::MissingType ); } @@ -2909,7 +2730,7 @@ mod kimi_tests { "required": ["function"], "additionalProperties": true }); - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) + sanitize_for_kimi_parameters(&mut schema) .expect("empty items must repair to the keyword being absent"); assert_eq!(schema["properties"]["args"], json!({"type": "array"})); @@ -2927,8 +2748,7 @@ mod kimi_tests { } } }); - sanitize_for_kimi_parameters(&mut nested, KimiParameterRoute::Platform) - .expect("nested empty items repair"); + sanitize_for_kimi_parameters(&mut nested).expect("nested empty items repair"); assert_eq!( nested["properties"]["outer"]["properties"]["inner"], json!({"type": "array"}) @@ -2941,8 +2761,7 @@ mod kimi_tests { } }); assert_eq!( - sanitize_for_kimi_parameters(&mut described_only, KimiParameterRoute::Platform) - .unwrap_err(), + sanitize_for_kimi_parameters(&mut described_only).unwrap_err(), KimiParameterSchemaError::MissingType ); } @@ -2960,8 +2779,7 @@ mod kimi_tests { "grid": {"type": "array", "items": {"items": {}}} } }); - sanitize_for_kimi_parameters(&mut grid, KimiParameterRoute::Platform) - .expect("items-in-items repair"); + sanitize_for_kimi_parameters(&mut grid).expect("items-in-items repair"); assert_eq!(grid["properties"]["grid"], json!({"type": "array"})); // A root `items` keyword beside `$defs`: the repair must visit every @@ -2973,8 +2791,7 @@ mod kimi_tests { "$defs": {"rows": {"type": "array", "items": {}}}, "properties": {"rows": {"$ref": "#/$defs/rows"}} }); - sanitize_for_kimi_parameters(&mut with_defs, KimiParameterRoute::Platform) - .expect("root items beside $defs repair"); + sanitize_for_kimi_parameters(&mut with_defs).expect("root items beside $defs repair"); assert!(with_defs.get("items").is_none()); assert_eq!(with_defs["$defs"]["rows"], json!({"type": "array"})); @@ -2998,7 +2815,7 @@ mod kimi_tests { } } }); - sanitize_for_kimi_parameters(&mut nested_positions, KimiParameterRoute::Platform) + sanitize_for_kimi_parameters(&mut nested_positions) .expect("additionalProperties and anyOf empty items repair"); assert_eq!( nested_positions["properties"]["map"]["additionalProperties"], @@ -3017,7 +2834,7 @@ mod kimi_tests { "properties": {"name": {"type": "string"}}, "additionalProperties": {"type": "array", "items": {}} }); - sanitize_for_kimi_parameters(&mut root_additional, KimiParameterRoute::Platform) + sanitize_for_kimi_parameters(&mut root_additional) .expect("root additionalProperties empty items repair"); assert_eq!( root_additional["additionalProperties"], @@ -3033,7 +2850,7 @@ mod kimi_tests { "properties": {"a": {"$ref": "#/$defs/a", "items": {}}}, "$defs": {"a": {"type": "string"}} }); - sanitize_for_kimi_parameters(&mut ref_with_items, KimiParameterRoute::Platform) + sanitize_for_kimi_parameters(&mut ref_with_items) .expect("$ref with an empty items sibling repairs"); assert_eq!( ref_with_items["properties"]["a"], @@ -3045,24 +2862,27 @@ mod kimi_tests { fn kimi_parameters_min_items_without_absent_items_terminates_upstream() { // Ported from codewhale-hq e3e563204 (the #7015 review follow-up): // once an empty `items` repairs to the keyword being absent, a - // required array with `minItems >= 1` no longer fails - // mfjs_schema_can_terminate with InvalidSchemaNode — MFJS allows - // `items` to be absent, and an unconstrained array can always be - // satisfied, so the repaired tool ships at every terminator-checked - // position. + // required array no longer fails mfjs_schema_can_terminate with + // InvalidSchemaNode — MFJS allows `items` to be absent, and an + // unconstrained array can always be satisfied, so the repaired tool + // ships at every terminator-checked position. `minItems` itself sits + // outside the specification's supported set, so the advisory strip + // removes it first and the wire carries the document-exact + // unconstrained shape. let mut schema = json!({ "type": "object", "properties": {"a": {"type": "array", "items": {}, "minItems": 1}}, "required": ["a"] }); - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) - .expect("a required minItems >= 1 array without items terminates"); + sanitize_for_kimi_parameters(&mut schema) + .expect("a required array without items terminates after the repairs"); assert_eq!( schema["properties"]["a"], - json!({"type": "array", "minItems": 1}) + json!({"type": "array"}), + "empty items and unlisted minItems both repair to the absent form" ); - // The same port applies at the terminator's other checked positions: + // The same repairs apply at the terminator's other checked positions: // an `anyOf` branch and a `$ref` target reached from a required // property. let mut schema = json!({ @@ -3077,11 +2897,11 @@ mod kimi_tests { }, "required": ["a"] }); - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) - .expect("a minItems >= 1 anyOf branch without items terminates"); + sanitize_for_kimi_parameters(&mut schema) + .expect("an anyOf branch array without items terminates"); assert_eq!( schema["properties"]["a"]["anyOf"][0], - json!({"type": "array", "minItems": 1}) + json!({"type": "array"}) ); let mut schema = json!({ @@ -3090,31 +2910,28 @@ mod kimi_tests { "properties": {"x": {"$ref": "#/$defs/a"}}, "required": ["x"] }); - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) - .expect("a minItems >= 1 $ref target without items terminates"); - assert_eq!( - schema["$defs"]["a"], - json!({"type": "array", "minItems": 1}) - ); + sanitize_for_kimi_parameters(&mut schema) + .expect("a $ref target array without items terminates"); + assert_eq!(schema["$defs"]["a"], json!({"type": "array"})); - // `minItems: 0` terminates trivially at the same checked position, - // and its only MFJS-legal shape repairs to the equivalent - // unconstrained form. + // A carried `minItems >= 1` beside present `items` keeps the items + // constraint out of the terminator's way only because the strip + // already removed the keyword; a direct unsanitized validation call + // still reads the conservative lower bound through the port. let mut schema = json!({ "type": "object", - "properties": {"a": {"type": "array", "items": {}, "minItems": 0}}, + "properties": {"a": {"type": "array", "minItems": 1, "items": {"type": "string"}}}, "required": ["a"] }); - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) - .expect("minItems: 0 beside empty items repairs"); + sanitize_for_kimi_parameters(&mut schema) + .expect("the carried items schema survives with minItems stripped"); assert_eq!( schema["properties"]["a"], - json!({"type": "array", "minItems": 0}) + json!({"type": "array", "items": {"type": "string"}}) ); - // Tuple semantics stay protected: with `prefixItems` present the - // first `minItems` prefix entries are still required, so a - // non-terminating prefix keeps the tool dropped. + // Tuple semantics stay out of the document entirely: `prefixItems` + // is unlisted, has no strip equivalent, and fails the tool closed. let schema = json!({ "type": "object", "properties": { @@ -3126,33 +2943,32 @@ mod kimi_tests { }, "required": ["a"] }); - validate_mfjs_parameters(&schema, KimiParameterRoute::Platform) - .expect_err("prefixItems is outside the platform whitelist"); - validate_mfjs_parameters(&schema, KimiParameterRoute::KimiCode) - .expect("a terminating tuple prefix ships on the Kimi Code route"); + validate_mfjs_parameters(&schema).expect_err("prefixItems is outside the document set"); let schema = json!({ "type": "object", "properties": { "a": { "type": "array", - "minItems": 1, "prefixItems": [ - {"type": "array", "minItems": 1, "items": {"$ref": "#"}} + {"type": "array", "items": {"$ref": "#"}} ] } }, "required": ["a"] }); - validate_mfjs_parameters(&schema, KimiParameterRoute::KimiCode) - .expect_err("a required non-terminating tuple prefix still fails closed"); + validate_mfjs_parameters(&schema) + .expect_err("a non-terminating tuple prefix still fails closed"); } - fn probe_verified_keyword_schema() -> Value { - // Every keyword the 2026-11-04 live probe of - // https://api.kimi.com/coding/v1 verified end to end (200, model - // honored pattern/prefixItems semantics), plus nested allOf which the - // same probe found accepted by the server. + fn probe_observed_keyword_schema() -> Value { + // Keywords the 2026-11-04 live probe of https://api.kimi.com/coding/v1 + // merely observed the server accepting (200), plus nested allOf. The + // MFJS specification does not list any of them, and the closing + // clause ("未被提及……我们不保证它们能在最终的结构化输出中产生稳定、 + // 符合预期的结果") leaves them unsupported — so the probe is an + // observation only and never a shipping basis: the wire stays on the + // document's supported set and these shapes keep dropping the tool. json!({ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", @@ -3160,20 +2976,16 @@ mod kimi_tests { "label": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$", - "format": "uri", - "maxLength": 40 + "format": "uri" }, "price": { "type": "number", - "exclusiveMinimum": 0, - "maximum": 100 + "exclusiveMinimum": 0 }, "rank": {"type": "integer", "exclusiveMaximum": 10}, "row": { "type": "array", - "prefixItems": [{"type": "string"}, {"type": "integer"}], - "minItems": 2, - "maxItems": 2 + "prefixItems": [{"type": "string"}, {"type": "integer"}] }, "target": { "type": "object", @@ -3195,59 +3007,70 @@ mod kimi_tests { } #[test] - fn kimi_code_route_ships_probe_verified_keywords() { - let mut schema = probe_verified_keyword_schema(); - let note = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::KimiCode) - .expect("every probe-verified keyword ships on the Kimi Code route"); - assert!(note.is_none()); - assert_eq!( - schema["properties"]["label"]["pattern"], - json!("^[a-z][a-z0-9-]*$") - ); - assert_eq!(schema["properties"]["label"]["format"], json!("uri")); - assert_eq!( - schema["$schema"], - json!("https://json-schema.org/draft/2020-12/schema") - ); - assert_eq!(schema["properties"]["price"]["exclusiveMinimum"], json!(0)); - assert_eq!(schema["properties"]["rank"]["exclusiveMaximum"], json!(10)); - assert_eq!( - schema["properties"]["row"]["prefixItems"], - json!([{"type": "string"}, {"type": "integer"}]) - ); - assert_eq!(schema["properties"]["row"]["minItems"], json!(2)); - assert_eq!( - schema["properties"]["target"]["allOf"] - .as_array() - .map(Vec::len), - Some(2), - "nested allOf passes through on the Kimi Code route: {schema}" + fn probe_observed_keywords_fail_closed_on_every_route() { + // Probe facts are observations only. No route may admit keywords the + // MFJS specification does not list: the whole tool degrades with a + // projection warning instead of shipping an unguaranteed shape. + let mut schema = probe_observed_keyword_schema(); + let error = sanitize_for_kimi_parameters(&mut schema) + .expect_err("probe-observed keywords stay outside the platform whitelist"); + assert!( + matches!( + error, + KimiParameterSchemaError::UnsupportedKeyword + | KimiParameterSchemaError::UnsupportedNestedAllOf + ), + "the first out-of-document shape fails closed: {error:?}" ); - validate_mfjs_parameters(&schema, KimiParameterRoute::KimiCode).unwrap(); - } - - #[test] - fn platform_route_keeps_the_conservative_whitelist() { - // The same document against the platform route must keep failing - // closed: no live probe backs the extra keywords there, the walle - // specification does not list them, and an unsupported keyword on a - // platform endpoint risks a whole-request 400 — the asymmetry that - // keeps that route strict. - let mut schema = probe_verified_keyword_schema(); - let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) - .expect_err("the platform whitelist stays conservative"); - // Which fixed variant fires first is pass-order dependent (the - // nested-allOf drop runs before the keyword whitelist), so pin the - // set of acceptable conservative drops, not one of them. + + let mut schema = probe_observed_keyword_schema(); + let error = sanitize_for_kimi_parameters(&mut schema) + .expect_err("probe-observed keywords stay outside the document whitelist"); assert!( matches!( error, KimiParameterSchemaError::UnsupportedKeyword | KimiParameterSchemaError::UnsupportedNestedAllOf ), - "the platform route rejects the first out-of-whitelist shape: {error:?}" + "the first out-of-document shape fails closed: {error:?}" ); + // Individual document-absent keywords, each pinned so a whitelist + // change cannot slip one back in silently. + for (keyword, node) in [ + ("pattern", json!({"type": "string", "pattern": "^a$"})), + ("format", json!({"type": "string", "format": "uri"})), + ( + "$schema", + json!({"type": "string", "$schema": "https://json-schema.org/draft/2020-12/schema"}), + ), + ( + "prefixItems", + json!({"type": "array", "prefixItems": [{"type": "string"}]}), + ), + ( + "exclusiveMinimum", + json!({"type": "number", "exclusiveMinimum": 0}), + ), + ( + "exclusiveMaximum", + json!({"type": "integer", "exclusiveMaximum": 10}), + ), + ] { + let mut schema = json!({ + "type": "object", + "properties": {"leaf": node}, + "required": ["leaf"] + }); + let error = sanitize_for_kimi_parameters(&mut schema) + .err() + .unwrap_or_else(|| panic!("{keyword} must fail closed, not sanitize")); + assert!( + matches!(error, KimiParameterSchemaError::UnsupportedKeyword), + "{keyword} must drop the tool with UnsupportedKeyword: {error:?}" + ); + } + let mut all_of_only = json!({ "type": "object", "properties": { @@ -3269,75 +3092,149 @@ mod kimi_tests { }); // Two branches: a single-branch allOf is collapsed by the shared // semantics-preserving pass before normalization ever sees it. - let error = sanitize_for_kimi_parameters(&mut all_of_only, KimiParameterRoute::Platform) - .expect_err("nested allOf keeps failing closed on the platform route"); + let error = sanitize_for_kimi_parameters(&mut all_of_only) + .expect_err("nested allOf keeps failing closed"); assert!( matches!(error, KimiParameterSchemaError::UnsupportedNestedAllOf), - "the platform route keeps the nested-allOf drop: {error:?}" - ); - - // And a plain schema keeps sanitizing identically on both routes. - let mut plain = json!({ - "type": "object", - "properties": {"path": {"type": "string"}, "label": {"const": "fixed"}}, - "required": ["path"] - }); - let mut expected = plain.clone(); - sanitize_for_kimi_parameters(&mut plain, KimiParameterRoute::Platform).unwrap(); - sanitize_for_kimi_parameters(&mut expected, KimiParameterRoute::KimiCode).unwrap(); - assert_eq!( - plain, expected, - "the relaxed whitelist must not change the shape of conservative documents" + "nested allOf keeps the fail-closed drop: {error:?}" ); } #[test] - fn kimi_code_route_keeps_core_mfjs_rules_strict() { - // The relaxations widen the whitelist only; every core fail-closed - // rule stays in force on the Kimi Code route too. + fn advisory_keywords_are_stripped_not_shipped() { + // Keywords the MFJS specification does not list but whose removal is + // a semantics-lossless or soft-constraint-losing simplification are + // stripped in place instead of dropping the tool: the annotation + // keywords (title, $comment, $id) and the unlisted bound keywords + // (minLength, maxLength, minimum, maximum, minItems, maxItems). The + // wire must carry only the document's supported set. let mut schema = json!({ + "title": "Outer annotation", + "$comment": "why this tool exists", + "$id": "https://example.com/outer", "type": "object", - "properties": {"a": {"type": "array", "items": true}}, - "required": ["a"] + "properties": { + "label": { + "title": "Label", + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "price": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "row": { + "type": "array", + "minItems": 1, + "maxItems": 2, + "items": {"type": "string"} + } + }, + "required": ["label"] }); - let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::KimiCode) - .expect_err("boolean items still fail closed"); - assert!(matches!(error, KimiParameterSchemaError::InvalidSchemaNode)); - let mut schema = json!({ - "type": "object", - "properties": {"choice": {"anyOf": [{"type": "string"}, {}]}} - }); - let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::KimiCode) - .expect_err("an empty anyOf branch still fails closed"); - assert!(matches!(error, KimiParameterSchemaError::MissingType)); + sanitize_for_kimi_parameters(&mut schema) + .expect("advisory keywords must not drop the tool"); + + for keyword in [ + "title", + "$comment", + "$id", + "minLength", + "maxLength", + "minimum", + "maximum", + "minItems", + "maxItems", + ] { + assert!( + !contains_key_anywhere(&schema, keyword), + "{keyword} must not reach the wire: {schema}" + ); + } + assert_eq!(schema["type"], "object"); + assert_eq!(schema["properties"]["label"]["type"], "string"); + assert_eq!(schema["properties"]["row"]["items"]["type"], "string"); + validate_mfjs_parameters(&schema).unwrap(); + // The same document sanitizes identically everywhere: one pipeline, + // one wire contract for every Moonshot route. + let mut reference = schema.clone(); + sanitize_for_kimi_parameters(&mut reference).unwrap(); + assert_eq!(schema, reference); + } + + #[test] + fn unsupported_enum_values_are_stripped_and_tool_survives() { + // The MFJS specification supports enum values only as float/int/str + // scalars of one single type ("枚举值我们只支持基础类型中的 float, + // int 和 str……不支持混合类型 (List 中的每个枚举值的类型必须相同)"). + // An enum carrying anything else loses the keyword — an + // unconstrained node stays shippable — instead of dropping the tool. let mut schema = json!({ "type": "object", "properties": { - "price": {"type": "string", "exclusiveMinimum": 0} + "mixed_kinds": {"type": "string", "enum": ["fast", 1]}, + "boolean_values": {"type": "boolean", "enum": [true]}, + "object_values": {"type": "object", "enum": [{"nested": true}]}, + "array_values": {"type": "array", "enum": [["a"]]} } }); - let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::KimiCode) - .expect_err("an exclusive bound on a string type still fails closed"); - assert!(matches!( - error, - KimiParameterSchemaError::InvalidKeywordValue - )); + sanitize_for_kimi_parameters(&mut schema) + .expect("unsupported enum values must strip, not drop the tool"); + + for name in [ + "mixed_kinds", + "boolean_values", + "object_values", + "array_values", + ] { + assert!( + schema["properties"][name].get("enum").is_none(), + "{name} must lose its unsupported enum: {schema}" + ); + } + assert_eq!(schema["properties"]["mixed_kinds"]["type"], "string"); + assert_eq!(schema["properties"]["boolean_values"]["type"], "boolean"); + assert_eq!(schema["properties"]["object_values"]["type"], "object"); + assert_eq!(schema["properties"]["array_values"]["type"], "array"); + validate_mfjs_parameters(&schema).unwrap(); + + // A node whose only keyword is an unsupported `enum` has no type to + // fall back to: the strip leaves an empty schema node, which fails + // closed with MissingType exactly as the baseline did — the strip + // never invents a type the schema author did not declare. + let mut untyped = json!({ + "type": "object", + "properties": {"leaf": {"enum": ["a", 1]}} + }); + let error = sanitize_for_kimi_parameters(&mut untyped) + .expect_err("an enum-only node has no type to survive on"); + assert!( + matches!(error, KimiParameterSchemaError::MissingType), + "{error:?}" + ); + + // Homogeneous scalar enums stay: str-only, int-only, and float-only + // are exactly the document's supported shapes. let mut schema = json!({ "type": "object", "properties": { - "price": { - "type": "number", - "exclusiveMinimum": 100, - "maximum": 50 - } + "role": {"type": "string", "enum": ["fast", "full"]}, + "level": {"type": "integer", "enum": [1, 2]}, + "ratio": {"type": "number", "enum": [0.5, 1.5]} } }); - let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::KimiCode) - .expect_err("an inverted bound pair still fails closed"); - assert!(matches!(error, KimiParameterSchemaError::InvalidRange)); + sanitize_for_kimi_parameters(&mut schema).unwrap(); + assert_eq!( + schema["properties"]["role"]["enum"], + json!(["fast", "full"]) + ); + assert_eq!(schema["properties"]["level"]["enum"], json!([1, 2])); + assert_eq!(schema["properties"]["ratio"]["enum"], json!([0.5, 1.5])); } #[test] @@ -3352,7 +3249,7 @@ mod kimi_tests { "choice": {"anyOf": [{"type": "string"}, {}]} } }); - let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) + let error = sanitize_for_kimi_parameters(&mut schema) .expect_err("an empty anyOf branch fails closed"); assert!( matches!(error, KimiParameterSchemaError::MissingType), @@ -3365,7 +3262,7 @@ mod kimi_tests { "choice": {"anyOf": [{"type": "string"}, {"items": {"items": {}}}]} } }); - let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) + let error = sanitize_for_kimi_parameters(&mut schema) .expect_err("a branch the nested repair empties to {} fails closed"); assert!( matches!(error, KimiParameterSchemaError::MissingType), @@ -3377,7 +3274,7 @@ mod kimi_tests { "properties": {"a": {"type": "string"}}, "$defs": {"x": {}} }); - let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) + let error = sanitize_for_kimi_parameters(&mut schema) .expect_err("an empty $defs entry fails closed"); assert!( matches!(error, KimiParameterSchemaError::MissingType), @@ -3389,8 +3286,8 @@ mod kimi_tests { "properties": {"a": {"type": "array", "items": true}}, "required": ["a"] }); - let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) - .expect_err("boolean items fail closed"); + let error = + sanitize_for_kimi_parameters(&mut schema).expect_err("boolean items fail closed"); assert!( matches!(error, KimiParameterSchemaError::InvalidSchemaNode), "boolean items drops with InvalidSchemaNode: {error:?}" @@ -3401,8 +3298,8 @@ mod kimi_tests { "properties": {"a": {"type": "array", "items": [{"type": "string"}]}}, "required": ["a"] }); - let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) - .expect_err("tuple-form items fail closed"); + let error = + sanitize_for_kimi_parameters(&mut schema).expect_err("tuple-form items fail closed"); assert!( matches!(error, KimiParameterSchemaError::InvalidSchemaNode), "tuple items drops with InvalidSchemaNode: {error:?}" @@ -3455,8 +3352,7 @@ mod kimi_tests { for mut schema in fixtures { let original = schema.clone(); - let error = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) - .unwrap_err(); + let error = sanitize_for_kimi_parameters(&mut schema).unwrap_err(); assert_eq!(error, KimiParameterSchemaError::NonTerminatingReference); for sentinel in ["private-root", "private-node", "private-to"] { assert!(!error.to_string().contains(sentinel)); @@ -3482,7 +3378,7 @@ mod kimi_tests { } } }); - sanitize_for_kimi_parameters(&mut optional, KimiParameterRoute::Platform).unwrap(); + sanitize_for_kimi_parameters(&mut optional).unwrap(); let mut nullable = json!({ "type": "object", @@ -3510,7 +3406,7 @@ mod kimi_tests { } } }); - sanitize_for_kimi_parameters(&mut nullable, KimiParameterRoute::Platform).unwrap(); + sanitize_for_kimi_parameters(&mut nullable).unwrap(); } #[test] @@ -3526,8 +3422,7 @@ mod kimi_tests { } }); assert_eq!( - sanitize_for_kimi_parameters(&mut too_many_branches, KimiParameterRoute::Platform) - .unwrap_err(), + sanitize_for_kimi_parameters(&mut too_many_branches).unwrap_err(), KimiParameterSchemaError::ResourceLimitExceeded ); @@ -3551,8 +3446,7 @@ mod kimi_tests { } }); assert_eq!( - sanitize_for_kimi_parameters(&mut too_many_properties, KimiParameterRoute::Platform) - .unwrap_err(), + sanitize_for_kimi_parameters(&mut too_many_properties).unwrap_err(), KimiParameterSchemaError::ResourceLimitExceeded ); @@ -3567,8 +3461,7 @@ mod kimi_tests { } }); assert_eq!( - sanitize_for_kimi_parameters(&mut too_many_enum_values, KimiParameterRoute::Platform) - .unwrap_err(), + sanitize_for_kimi_parameters(&mut too_many_enum_values).unwrap_err(), KimiParameterSchemaError::ResourceLimitExceeded ); } @@ -3585,7 +3478,7 @@ mod kimi_tests { }); } assert_eq!( - sanitize_for_kimi_parameters(&mut too_deep, KimiParameterRoute::Platform).unwrap_err(), + sanitize_for_kimi_parameters(&mut too_deep).unwrap_err(), KimiParameterSchemaError::ResourceLimitExceeded ); @@ -3601,8 +3494,7 @@ mod kimi_tests { } }); assert_eq!( - sanitize_for_kimi_parameters(&mut too_much_enum_text, KimiParameterRoute::Platform) - .unwrap_err(), + sanitize_for_kimi_parameters(&mut too_much_enum_text).unwrap_err(), KimiParameterSchemaError::ResourceLimitExceeded ); } @@ -3619,7 +3511,7 @@ mod kimi_tests { "empty": {"type": "null", "default": null} } }); - sanitize_for_kimi_parameters(&mut valid, KimiParameterRoute::Platform).unwrap(); + sanitize_for_kimi_parameters(&mut valid).unwrap(); for (mut invalid, expected) in [ ( @@ -3657,8 +3549,7 @@ mod kimi_tests { ), ] { let original = invalid.clone(); - let error = sanitize_for_kimi_parameters(&mut invalid, KimiParameterRoute::Platform) - .unwrap_err(); + let error = sanitize_for_kimi_parameters(&mut invalid).unwrap_err(); assert_eq!(error, expected); assert!(!error.to_string().contains("private-default")); assert_eq!(invalid, original); @@ -3666,44 +3557,68 @@ mod kimi_tests { } #[test] - fn kimi_parameters_rejects_inverted_and_fractional_integer_bounds() { - for mut schema in [ - json!({ - "type": "object", - "properties": { - "value": {"type": "string", "minLength": 5, "maxLength": 4} - } - }), - json!({ - "type": "object", - "properties": { - "value": {"type": "array", "minItems": 3, "maxItems": 2} - } - }), - json!({ - "type": "object", - "properties": { - "value": {"type": "number", "minimum": 10, "maximum": 9} - } - }), - json!({ - "type": "object", - "properties": { - "value": {"type": "integer", "minimum": 1.5} - } - }), - json!({ - "type": "object", - "properties": { - "value": {"type": "integer", "maximum": 2.5} - } - }), + fn kimi_parameters_strips_bounds_instead_of_rejecting_them() { + // The bound keywords sit outside the specification's supported set, + // so even malformed or inverted bounds never degrade the tool: the + // advisory strip removes them (losing only the soft constraint) and + // the wire carries the unconstrained node. No bound value is ever + // validated, because none ever ships. + for (mut schema, keywords) in [ + ( + json!({ + "type": "object", + "properties": { + "value": {"type": "string", "minLength": 5, "maxLength": 4} + } + }), + vec!["minLength", "maxLength"], + ), + ( + json!({ + "type": "object", + "properties": { + "value": {"type": "array", "minItems": 3, "maxItems": 2} + } + }), + vec!["minItems", "maxItems"], + ), + ( + json!({ + "type": "object", + "properties": { + "value": {"type": "number", "minimum": 10, "maximum": 9} + } + }), + vec!["minimum", "maximum"], + ), + ( + json!({ + "type": "object", + "properties": { + "value": {"type": "integer", "minimum": 1.5} + } + }), + vec!["minimum"], + ), + ( + json!({ + "type": "object", + "properties": { + "value": {"type": "integer", "maximum": 2.5} + } + }), + vec!["maximum"], + ), ] { - assert_eq!( - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) - .unwrap_err(), - KimiParameterSchemaError::InvalidRange - ); + sanitize_for_kimi_parameters(&mut schema) + .expect("bounds must strip, never degrade the tool"); + for keyword in keywords { + assert!( + !contains_key_anywhere(&schema, keyword), + "{keyword} must not reach the wire: {schema}" + ); + } + validate_mfjs_parameters(&schema).expect("the stripped schema must validate"); } } @@ -3721,7 +3636,7 @@ mod kimi_tests { "description": "File arguments" }); - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap(); + sanitize_for_kimi_parameters(&mut schema).unwrap(); assert_eq!(schema["type"], "object"); assert_eq!(schema["properties"]["path"]["type"], "string"); @@ -3740,8 +3655,7 @@ mod kimi_tests { }); let original = schema.clone(); - let error = - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap_err(); + let error = sanitize_for_kimi_parameters(&mut schema).unwrap_err(); assert_eq!(error, KimiParameterSchemaError::UnresolvedRootReference); assert!(!error.to_string().contains("private-schema-name-9217")); @@ -3780,8 +3694,7 @@ mod kimi_tests { fn kimi_degrades_dependent_keywords_to_the_flat_schema() { let mut schema = action_discriminated_schema(); - let note = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) - .expect("must not refuse the schema"); + let note = sanitize_for_kimi_parameters(&mut schema).expect("must not refuse the schema"); assert!(!contains_key_anywhere(&schema, "dependentSchemas")); assert!(!contains_key_anywhere(&schema, "dependentRequired")); @@ -3790,8 +3703,7 @@ mod kimi_tests { assert!(properties.contains_key(name), "{name} left the flat schema"); } assert_eq!(schema["required"], json!(["action"])); - validate_mfjs_parameters(&schema, KimiParameterRoute::Platform) - .expect("degraded schema must validate"); + validate_mfjs_parameters(&schema).expect("degraded schema must validate"); let note = note.expect("dropping a requirement must be reported to the model"); assert_eq!( @@ -3809,8 +3721,7 @@ mod kimi_tests { "required": ["path"] }); - let note = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) - .expect("plain schema"); + let note = sanitize_for_kimi_parameters(&mut schema).expect("plain schema"); assert_eq!( note, None, @@ -3835,8 +3746,7 @@ mod kimi_tests { } }); - let note = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) - .expect("nested composition"); + let note = sanitize_for_kimi_parameters(&mut schema).expect("nested composition"); assert!(!contains_key_anywhere(&schema, "dependentRequired")); assert!( @@ -3845,8 +3755,7 @@ mod kimi_tests { .expect("nested properties") .contains_key("path") ); - validate_mfjs_parameters(&schema, KimiParameterRoute::Platform) - .expect("degraded schema must validate"); + validate_mfjs_parameters(&schema).expect("degraded schema must validate"); assert!(note.is_some(), "nested drop must still be reported"); } @@ -3859,8 +3768,7 @@ mod kimi_tests { "dependentSchemas": ["invalid"] }); - let note = sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform) - .expect("degraded schema"); + let note = sanitize_for_kimi_parameters(&mut schema).expect("degraded schema"); assert!(note.is_some(), "every dropped dependency must be reported"); assert!(!contains_key_anywhere(&schema, "dependentRequired")); @@ -3877,8 +3785,7 @@ mod kimi_tests { }); let original = schema.clone(); - let error = - sanitize_for_kimi_parameters(&mut schema, KimiParameterRoute::Platform).unwrap_err(); + let error = sanitize_for_kimi_parameters(&mut schema).unwrap_err(); assert_eq!(error, KimiParameterSchemaError::ReferencedRootMustBeObject); assert!(!error.to_string().contains("private-scalar-name-4831")); @@ -3897,15 +3804,17 @@ mod kimi_tests { // required fields instead of collapsing to layout+title-only pages. This // test pins the seam between the host-side schema authoring and this // Moonshot compatibility layer: the shipped schema must normalize, pass - // the MFJS validator, and keep its union structure on both Moonshot - // routes. A schema drift past an MFJS budget turns the normalization red; - // a renewed flattening of nested unions turns the branch assertions red — + // the MFJS validator, keep its nested union structure, and carry only + // keywords inside the specification's supported set. A schema drift past + // an MFJS budget turns the normalization red; a renewed flattening of + // nested unions turns the branch assertions red; a keyword drifting + // outside the document's supported set turns the wire-keyword matrix red — // each on exactly the side that regressed. const SHIPPED_PPTX_TOOL_DEFS: &str = include_str!("../../tests/fixtures/pptx_make_pptx_schema.json"); #[test] - fn shipped_pptx_slides_schema_passes_moonshot_validation_on_both_routes() { + fn shipped_pptx_slides_schema_passes_moonshot_validation_on_the_document_set() { let fixture: Value = serde_json::from_str(SHIPPED_PPTX_TOOL_DEFS) .expect("the exported pptx tool definitions must stay valid JSON"); let shipped = Value::Object( @@ -3958,51 +3867,109 @@ mod kimi_tests { ); let expected = branch_contract(shipped_branches); - for route in [KimiParameterRoute::Platform, KimiParameterRoute::KimiCode] { - let mut parameters = shipped.clone(); - let note = sanitize_for_kimi_parameters(&mut parameters, route) - .expect("the shipped make_pptx schema must pass Moonshot normalization"); - assert_eq!( - note, None, - "the shipped schema drops no composition, so it must stay unannotated" - ); - validate_mfjs_parameters(¶meters, route) - .expect("the normalized schema must satisfy the MFJS validator"); + let mut parameters = shipped.clone(); + let note = sanitize_for_kimi_parameters(&mut parameters) + .expect("the shipped make_pptx schema must pass Moonshot normalization"); + assert_eq!( + note, None, + "the shipped schema drops no composition, so it must stay unannotated" + ); + validate_mfjs_parameters(¶meters) + .expect("the normalized schema must satisfy the MFJS validator"); - // The root stays the plain object schema the tool declared. - assert_eq!(parameters["type"], "object"); - assert_eq!(parameters["required"], json!(["slides"])); + // The root stays the plain object schema the tool declared. + assert_eq!(parameters["type"], "object"); + assert_eq!(parameters["required"], json!(["slides"])); - // The union survives intact: no flatten into a merged object, - // same branch count, same per-branch layout/required contract. - let items = ¶meters["properties"]["slides"]["items"]; - assert!( - items.get("properties").is_none(), - "slides.items must not be flattened into a merged object: {items}" - ); - let branches = items["anyOf"] - .as_array() - .expect("the slides.items union must survive normalization"); + // The union survives intact: no flatten into a merged object, same + // branch count, same per-branch layout/required contract. + let items = ¶meters["properties"]["slides"]["items"]; + assert!( + items.get("properties").is_none(), + "slides.items must not be flattened into a merged object: {items}" + ); + let branches = items["anyOf"] + .as_array() + .expect("the slides.items union must survive normalization"); + assert_eq!( + branches.len(), + 10, + "normalization must not change the branch count" + ); + assert_eq!(branch_contract(branches), expected); + + // The nested item union (agenda/kpi entries) survives too. + for layout in ["agenda", "kpi"] { + let branch = branches + .iter() + .find(|branch| branch["properties"]["layout"]["enum"][0] == json!(layout)) + .expect("layout branch"); + let nested = &branch["properties"]["items"]["items"]["anyOf"]; assert_eq!( - branches.len(), - 10, - "normalization must not change the branch count" + nested.as_array().map(Vec::len), + Some(2), + "the {layout} item union must keep both branches: {nested}" ); - assert_eq!(branch_contract(branches), expected); + } - // The nested item union (agenda/kpi entries) survives too. - for layout in ["agenda", "kpi"] { - let branch = branches - .iter() - .find(|branch| branch["properties"]["layout"]["enum"][0] == json!(layout)) - .expect("layout branch"); - let nested = &branch["properties"]["items"]["items"]["anyOf"]; - assert_eq!( - nested.as_array().map(Vec::len), - Some(2), - "the {layout} item union must keep both branches: {nested}" - ); + // Wire-keyword matrix: every keyword anywhere on the normalized + // document must sit inside the MFJS specification's supported set. + // The `layout`/`title`-style names below the schema value positions + // are property names, not keywords, so the walk only counts keys at + // schema nodes: the root, property values, `items`, + // object-form `additionalProperties`, `anyOf` branches, and `$defs` + // entries. Anything the parent-side schema adds beyond the document + // set must either be stripped here (advisory keywords) or drop the + // tool — never ship. + let supported: &[&str] = &[ + "$defs", + "$ref", + "anyOf", + "properties", + "additionalProperties", + "items", + "type", + "enum", + "required", + "default", + "description", + ]; + let mut wire_keywords: Vec = Vec::new(); + fn collect_schema_keywords(value: &Value, keywords: &mut Vec) { + let Some(obj) = value.as_object() else { + return; + }; + keywords.extend(obj.keys().cloned()); + if let Some(properties) = obj.get("properties").and_then(Value::as_object) { + for child in properties.values() { + collect_schema_keywords(child, keywords); + } + } + if let Some(definitions) = obj.get("$defs").and_then(Value::as_object) { + for child in definitions.values() { + collect_schema_keywords(child, keywords); + } + } + if let Some(branches) = obj.get("anyOf").and_then(Value::as_array) { + for branch in branches { + collect_schema_keywords(branch, keywords); + } + } + if let Some(additional) = obj.get("additionalProperties").filter(|a| a.is_object()) { + collect_schema_keywords(additional, keywords); + } + if let Some(items) = obj.get("items") { + collect_schema_keywords(items, keywords); } } + collect_schema_keywords(¶meters, &mut wire_keywords); + wire_keywords.sort(); + wire_keywords.dedup(); + assert!( + wire_keywords + .iter() + .all(|keyword| supported.contains(&keyword.as_str())), + "only specification-supported keywords may reach the wire: {wire_keywords:?}" + ); } } diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 87c124a456..6c89e4085c 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -6072,11 +6072,8 @@ fn provider_schema_sanitizers_preserve_the_closed_fleet_role_enum() { // Moonshot/Kimi must retain the supported field vocabulary while dropping // the one root dependency keyword MFJS cannot represent. let mut kimi = agent_schema.clone(); - schema_sanitize::sanitize_for_kimi_parameters( - &mut kimi, - schema_sanitize::KimiParameterRoute::KimiCode, - ) - .expect("agent schema must stay Kimi-compatible"); + schema_sanitize::sanitize_for_kimi_parameters(&mut kimi) + .expect("agent schema must stay Kimi-compatible"); assert_eq!( kimi["properties"]["type"]["enum"], expected, "kimi sanitize must not erase or widen the role enum" @@ -6204,11 +6201,8 @@ fn agent_tool_schema_rejects_empty_input_across_provider_forms() { forms.push(("responses", responses)); let mut kimi = agent_schema; - schema_sanitize::sanitize_for_kimi_parameters( - &mut kimi, - schema_sanitize::KimiParameterRoute::KimiCode, - ) - .expect("agent schema must stay Kimi-compatible"); + schema_sanitize::sanitize_for_kimi_parameters(&mut kimi) + .expect("agent schema must stay Kimi-compatible"); forms.push(("kimi", kimi)); let empty = json!({}); From faf5a8c554f97dfc7d6555cc67313607fe8e4271 Mon Sep 17 00:00:00 2001 From: asto Date: Thu, 5 Nov 2026 03:14:21 +0800 Subject: [PATCH 09/20] docs(tui): pin the Moonshot schema policy to the published documents Record the 2026-11-05 ruling in the module documentation: the wire follows the published MFJS specification strictly, live probes are observations that may only ever tighten the contract, and when the walle source and its published specification disagree the specification text governs (with the concrete source-comparison note: SupportedKeywords carries title/$id/min-max and ValidateLevelDefault==ultra, none of which the document lists). One pipeline serves every Moonshot route because all of them document the same specification. Rewrite the fixed-sampling narrative to the same standard: the shipping basis is the documented "omit them from requests" contract, with the 2026-11-04 6/6-model probe kept as an observation record, and the overseas host joining on the document rather than on probe extrapolation. No behavior change; 71 schema_sanitize tests green, clippy and fmt clean. Signed-off-by: asto --- crates/tui/src/client/chat.rs | 47 ++++++++++++++----------- crates/tui/src/tools/schema_sanitize.rs | 35 ++++++++++++++++++ 2 files changed, 61 insertions(+), 21 deletions(-) diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 3fe6cf6121..05d01092b4 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -982,26 +982,29 @@ fn kimi_code_sampling_route(base_url: &str) -> bool { /// regardless of model id; custom gateways and the direct platform route /// keep their own wire contract. /// -/// The strip is deliberately route-scoped, not roster-scoped: a 2026-11-04 -/// live probe of the China endpoint (GET /models plus per-model requests) -/// showed all six served ids — `k3`, `k3-256k`, `k3.1-pro`, -/// `k3.1-pro-highspeed`, `kimi-for-coding`, `kimi-for-coding-highspeed` — -/// reject anything but the fixed values with a 400 ("invalid temperature: -/// only 1 is allowed for this model" / "invalid top_p: only 0.95 is allowed -/// for this model"). The roster has already lagged behind this endpoint -/// three times (`k3-256k`, `kimi-for-coding-highspeed`, then both `k3.1-pro` -/// spellings), so any model id the endpoint serves next is covered without a -/// code change. +/// The strip is deliberately route-scoped, not roster-scoped. The document +/// backing is the Kimi Code guide's own instruction to omit generic sampling +/// on these endpoints ("omit them from requests", +/// , +/// verified 2026-08-26 and re-verified 2026-11-05). The 2026-11-04 live +/// probe of the China endpoint (GET /models plus per-model requests) is an +/// observation recorded alongside it, never the shipping basis: all six +/// served ids — `k3`, `k3-256k`, `k3.1-pro`, `k3.1-pro-highspeed`, +/// `kimi-for-coding`, `kimi-for-coding-highspeed` — rejected anything but +/// the fixed values with a 400 ("invalid temperature: only 1 is allowed for +/// this model" / "invalid top_p: only 0.95 is allowed for this model"). The +/// roster has already lagged behind this endpoint three times (`k3-256k`, +/// `kimi-for-coding-highspeed`, then both `k3.1-pro` spellings), so any +/// model id the endpoint serves next is covered without a code change. /// -/// The overseas host `api.kimi.ai` joins on contract extrapolation: the -/// official documentation presents both regional endpoints as the same -/// product with the same wire contract, and the 6/6-model 400 probe covered -/// only the China spelling. The failure mode of the extrapolation is benign — -/// not sending `temperature`/`top_p` is exactly the official recommended -/// configuration for this endpoint, so an `.ai` route that would have -/// tolerated generic sampling merely ships the documented shape instead. -/// Source: -/// (verified 2026-08-26; probe verified 2026-11-04). +/// The overseas host `api.kimi.ai` joins on the same document: the official +/// documentation presents both regional endpoints as the same product with +/// the same wire contract (the 6/6-model probe covered only the China +/// spelling — an observation on one host, not a widening of another). The +/// failure mode is benign — not sending `temperature`/`top_p` is exactly +/// the documented configuration for this endpoint, so an `.ai` route that +/// would have tolerated generic sampling merely ships the documented shape +/// instead. fn apply_kimi_code_fixed_sampling(body: &mut Value, provider: ApiProvider, base_url: &str) { if provider != ApiProvider::Moonshot || !kimi_code_sampling_route(base_url) { return; @@ -6575,8 +6578,10 @@ mod alias_thinking_detection_tests { #[test] fn kimi_code_fixed_sampling_covers_every_model_id_on_the_route() { - // 2026-11-04 live probe (GET /models + per-model requests): all six - // ids served by https://api.kimi.com/coding/v1 answer + // Observation record (2026-11-04 live probe, GET /models + per-model + // requests; the shipping basis is the documented "omit them from + // requests" contract): all six ids served by + // https://api.kimi.com/coding/v1 answer // "invalid temperature: only 1 is allowed for this model" / // "invalid top_p: only 0.95 is allowed for this model" with a 400. // The roster has lagged this endpoint three times already diff --git a/crates/tui/src/tools/schema_sanitize.rs b/crates/tui/src/tools/schema_sanitize.rs index 918849b566..1d813a5150 100644 --- a/crates/tui/src/tools/schema_sanitize.rs +++ b/crates/tui/src/tools/schema_sanitize.rs @@ -10,6 +10,41 @@ //! `ToolRegistry::tools_for_api()` before the registry hands them off. //! Provider-specific helpers below add stricter DeepSeek and OpenAI Responses //! compatibility passes where their request shapes need it. +//! +//! # Moonshot / Kimi tool-schema policy (ruling of 2026-11-05) +//! +//! The wire shape sent to Moonshot function-calling endpoints is governed +//! strictly by the published MFJS specification +//! (, +//! fetched 2026-11-05; the English original lives at `docs/mfjs-spec.md` in +//! the same repository). Three rules follow from that ruling: +//! +//! 1. The wire carries only the specification's supported keyword set. Its +//! closing clause is the authority for everything else: "若官方规范中的 +//! 某些概念、关键字或特性在本文档中未被提及,则表示当前 MFJS 尚未提供官方 +//! 支持……我们不保证它们能在最终的结构化输出中产生稳定、符合预期的结果". +//! 2. Live probes are observations only and can never widen the wire. A +//! 2026-11-04 probe of `https://api.kimi.com/coding/v1` observed the +//! endpoint accepting `pattern`, `format`, `$schema`, `prefixItems`, +//! exclusive bounds, nested `allOf`, and a root `anyOf` union with 200 — +//! none of that ships, because the specification does not list it. A +//! probe may motivate tightening (it did: the route-tier whitelists this +//! module carried for one day were removed on exactly that evidence's +//! insufficiency), never loosening. +//! 3. When the documents and the server source disagree, the documents +//! govern. Source-comparison note: the walle implementation +//! (`model.go`, `SupportedKeywords`) additionally accepts `title`, `$id`, +//! `minLength`, `maxLength`, `minimum`, `maximum`, `minItems`, +//! `maxItems`, and `ValidateLevelDefault == "ultra"` validates with that +//! wider set — the published specification does not list any of them, so +//! this module takes the narrower face: those keywords are stripped +//! upstream of validation (`strip_mfjs_advisory_keywords`), never +//! shipped. +//! +//! One pipeline serves every Moonshot route (platform `api.moonshot.cn`/`ai` +//! and coding `api.kimi.com` / `api.kimi.ai`): all of those routes' +//! documentation points at the same specification, so a per-route whitelist +//! tier would be precision without a mandate. use std::collections::HashSet; From c24f8b0c59a36d88f0179c73f4f53c7d5e4012e0 Mon Sep 17 00:00:00 2001 From: asto Date: Thu, 5 Nov 2026 03:17:11 +0800 Subject: [PATCH 10/20] fix(tui): drop the dead composition walk from the MFJS depth checker The depth walker still visited allOf/prefixItems branch schemas, a leftover from the route-tier whitelists. Those keywords sit outside the documented set and fail the keyword whitelist before any depth walk can see them, so the visits were dead code. The walker returns to the baseline shape (properties, items, additionalProperties, anyOf). Signed-off-by: asto --- crates/tui/src/tools/schema_sanitize.rs | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/crates/tui/src/tools/schema_sanitize.rs b/crates/tui/src/tools/schema_sanitize.rs index 1d813a5150..e15bb16774 100644 --- a/crates/tui/src/tools/schema_sanitize.rs +++ b/crates/tui/src/tools/schema_sanitize.rs @@ -2260,21 +2260,6 @@ fn validate_mfjs_expanded_depth( )?; } } - // Nested allOf and tuple prefixItems (Kimi Code route): their branch - // schemas are ordinary schema positions the depth walker must visit. - for composition_key in ["allOf", "prefixItems"] { - if let Some(branches) = obj.get(composition_key).and_then(Value::as_array) { - for branch in branches { - validate_mfjs_expanded_depth( - branch, - document, - property_depth, - visiting_refs, - structural_depth + 1, - )?; - } - } - } Ok(()) } From 2ef8dff8d029095b3e03a70a9e8fc2b449cf779d Mon Sep 17 00:00:00 2001 From: asto Date: Thu, 5 Nov 2026 13:02:52 +0800 Subject: [PATCH 11/20] fix(tui): carry the MFJS default keyword verbatim on any node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Moonshot MFJS specification's default clause is explicit: "default 字段为目标输出提供一个默认值……这儿不做类型检查,需要调用者自行保证 其值类型内容和所在位置期望的内容相匹配" — any value, any node, and the caller owns the value's correctness (https://github.com/MoonshotAI/walle/blob/main/docs/mfjs-spec.zh.md, fetched 2026-11-05). The engine was stricter than the document on two counts: mfjs_keyword_allowed_for_type admitted default only on scalar nodes, and validate_mfjs_default failed the whole tool closed when a default's value did not match the node's declared type. Both over-tightenings are removed: default now sits beside every concrete type (object and array included) and its value ships unchecked, so a third-party tool declaring {"type":"object","default":{}} keeps shipping instead of being dropped wholesale. The type-absence rule is untouched — a node carrying only a default still fails MissingType. Signed-off-by: asto --- crates/tui/src/tools/schema_sanitize.rs | 120 ++++++++++-------------- 1 file changed, 52 insertions(+), 68 deletions(-) diff --git a/crates/tui/src/tools/schema_sanitize.rs b/crates/tui/src/tools/schema_sanitize.rs index e15bb16774..8683f61541 100644 --- a/crates/tui/src/tools/schema_sanitize.rs +++ b/crates/tui/src/tools/schema_sanitize.rs @@ -1248,8 +1248,6 @@ pub enum KimiParameterSchemaError { ResourceLimitExceeded, #[error("Moonshot function parameters contain a non-terminating MFJS reference")] NonTerminatingReference, - #[error("Moonshot function parameters contain an invalid MFJS default")] - InvalidDefault, } /// Normalize a complete Kimi / Moonshot `function.parameters` object. @@ -1893,9 +1891,6 @@ impl<'a> MfjsValidationState<'a> { if let Some(values) = obj.get("enum") { validate_mfjs_enum(values, schema_type, self)?; } - if let Some(default) = obj.get("default") { - validate_mfjs_default(default, schema_type)?; - } if let Some(properties) = obj.get("properties") { let properties = properties @@ -1981,18 +1976,25 @@ fn mfjs_keyword_allowed_for_type(keyword: &str, schema_type: &str, is_root: bool // and no longer appear anywhere on the wire (the sanitizer strips the // advisory ones); only a direct unsanitized validation call can see them // here, and they fail closed like any other unlisted keyword. + // + // `default` is the one keyword the specification deliberately keeps + // type-free: "default 字段为目标输出提供一个默认值……这儿不做类型检查, + // 需要调用者自行保证其值类型内容和所在位置期望的内容相匹配" (any value, + // any node; , fetched 2026-11-05). The engine therefore neither + // restricts its placement by node type nor type-checks its value — the + // caller owns the value's correctness, exactly as the document says. if is_root && keyword == "$defs" { return true; } - if matches!(keyword, "type" | "description") { + if matches!(keyword, "type" | "description" | "default") { return true; } match schema_type { "object" => matches!(keyword, "properties" | "required" | "additionalProperties"), "array" => keyword == "items", - "string" => matches!(keyword, "enum" | "default"), - "number" | "integer" => matches!(keyword, "enum" | "default"), - "boolean" | "null" => keyword == "default", + "string" | "number" | "integer" => keyword == "enum", + "boolean" | "null" => false, _ => false, } } @@ -2060,22 +2062,6 @@ fn validate_mfjs_enum( Ok(()) } -fn validate_mfjs_default(value: &Value, schema_type: &str) -> Result<(), KimiParameterSchemaError> { - let valid = match schema_type { - "boolean" => value.is_boolean(), - "number" => value.is_number(), - "integer" => mfjs_integer_value(value).is_some(), - "string" => value.is_string(), - "null" => value.is_null(), - _ => false, - }; - if valid { - Ok(()) - } else { - Err(KimiParameterSchemaError::InvalidDefault) - } -} - fn mfjs_integer_value(value: &Value) -> Option { let value = value.as_number()?.as_f64()?; (value.is_finite() && value.fract() == 0.0 && value.abs() <= MFJS_MAX_SAFE_INTEGER) @@ -3520,7 +3506,14 @@ mod kimi_tests { } #[test] - fn kimi_parameters_validates_default_type_and_placement_without_leaks() { + fn kimi_parameters_ships_default_on_any_node_without_type_check() { + // The specification's `default` clause, verbatim: "default 字段为 + // 目标输出提供一个默认值……这儿不做类型检查,需要调用者自行保证其值 + // 类型内容和所在位置期望的内容相匹配" (any value, any node; + // https://github.com/MoonshotAI/walle/blob/main/docs/mfjs-spec.zh.md, + // fetched 2026-11-05). The wire therefore carries `default` verbatim: + // no per-type placement rejection and no value/type mismatch + // fail-closed — the caller owns the value's correctness. let mut valid = json!({ "type": "object", "properties": { @@ -3528,52 +3521,43 @@ mod kimi_tests { "count": {"type": "integer", "default": 3}, "ratio": {"type": "number", "default": 1.5}, "label": {"type": "string", "default": "default-label"}, - "empty": {"type": "null", "default": null} + "empty": {"type": "null", "default": null}, + "mismatched": { + "type": "integer", + "default": "private-default-value-3302" + }, + "object_valued": { + "type": "object", + "properties": {"a": {"type": "string"}}, + "default": {"a": "b"} + }, + "array_valued": { + "type": "array", + "items": {"type": "string"}, + "default": ["x"] + } } }); sanitize_for_kimi_parameters(&mut valid).unwrap(); + assert_eq!( + valid["properties"]["mismatched"]["default"], + json!("private-default-value-3302") + ); + assert_eq!(valid["properties"]["object_valued"]["default"], json!({"a": "b"})); + assert_eq!(valid["properties"]["array_valued"]["default"], json!(["x"])); - for (mut invalid, expected) in [ - ( - json!({ - "type": "object", - "properties": { - "private-default-3301": { - "type": "integer", - "default": "private-default-value-3302" - } - } - }), - KimiParameterSchemaError::InvalidDefault, - ), - ( - json!({ - "type": "object", - "properties": { - "private-untyped-default-3303": {"default": 1} - } - }), - KimiParameterSchemaError::MissingType, - ), - ( - json!({ - "type": "object", - "properties": { - "private-object-default-3304": { - "type": "object", - "default": {} - } - } - }), - KimiParameterSchemaError::InvalidKeywordValue, - ), - ] { - let original = invalid.clone(); - let error = sanitize_for_kimi_parameters(&mut invalid).unwrap_err(); - assert_eq!(error, expected); - assert!(!error.to_string().contains("private-default")); - assert_eq!(invalid, original); - } + // A node that only carries `default` still fails the type-absence + // rule (MissingType) — that rejection is about the node, not about + // the default keyword or its value. + let mut untyped = json!({ + "type": "object", + "properties": {"private-untyped-default-3303": {"default": 1}} + }); + let original = untyped.clone(); + let error = sanitize_for_kimi_parameters(&mut untyped).unwrap_err(); + assert_eq!(error, KimiParameterSchemaError::MissingType); + assert!(!error.to_string().contains("private-untyped-default")); + assert_eq!(untyped, original); } #[test] From ef53ea2eede97380476b676f318e76552466e8d9 Mon Sep 17 00:00:00 2001 From: asto Date: Thu, 5 Nov 2026 13:08:19 +0800 Subject: [PATCH 12/20] fix(tui): align the MFJS resource budgets with the walle validator defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five engine-local budgets (anyOf items 10, total properties 100, object depth 5, enum items 500, enum string length 7500) had no annotated basis and sat an unexplained 10x under the walle validator's own defaults, so a third-party schema the server's validator would accept was dropped from every Moonshot turn. They now mirror conf.go DefaultValidatorConfig (source comparison, fetched 2026-11-05, declared in the constant block as a source note rather than a document clause): 500 / 3000 / 30 / 1000 / 75000. MaxSchemaSize already matched; the enum-length check threshold stays engine-local (the same budget, armed earlier); MFJS_MAX_STRUCTURAL_DEPTH stays as the engine's own recursion guard. Limit tests now pin both faces of each boundary — the aligned value ships, one past it fails — and the pptx sentinel moves from the old branch-count==budget coincidence to branch count far below the aligned budget with the full pipeline still green. Signed-off-by: asto --- crates/tui/src/tools/schema_sanitize.rs | 180 ++++++++++++++++-------- 1 file changed, 123 insertions(+), 57 deletions(-) diff --git a/crates/tui/src/tools/schema_sanitize.rs b/crates/tui/src/tools/schema_sanitize.rs index 8683f61541..b248e14a91 100644 --- a/crates/tui/src/tools/schema_sanitize.rs +++ b/crates/tui/src/tools/schema_sanitize.rs @@ -1748,12 +1748,25 @@ pub fn validate_mfjs_parameters(parameters: &Value) -> Result<(), KimiParameterS Ok(()) } -const MFJS_MAX_ANY_OF_ITEMS: usize = 10; -const MFJS_MAX_OBJECT_DEPTH: usize = 5; -const MFJS_MAX_TOTAL_PROPERTIES: usize = 100; -const MFJS_MAX_TOTAL_ENUM_VALUES: usize = 500; +// Resource budgets mirror the walle validator's own defaults (source +// comparison against `conf.go`, `DefaultValidatorConfig`, fetched from +// on 2026-11-05 — a source-comparison +// note, not a document clause: the MFJS specification sets no numeric +// limits): MaxAnyOfItems=500, MaxTotalPropertiesKeysNum=3000, +// MaxSchemaDepth=30, MaxEnumItems=1000, MaxEnumStringLength=75000. +// MaxSchemaSize=120000 below already matches. The alignment exists so a +// third-party schema the server's own validator would accept is not dropped +// by an engine-local tightening. The enum-length check threshold stays +// engine-local: applying the same MaxEnumStringLength budget from 250 values +// up is a stricter checking cadence than walle's 2500-value threshold, never +// a wider budget. MFJS_MAX_STRUCTURAL_DEPTH is engine-only — a recursion +// guard for this walker, not a walle budget. +const MFJS_MAX_ANY_OF_ITEMS: usize = 500; +const MFJS_MAX_OBJECT_DEPTH: usize = 30; +const MFJS_MAX_TOTAL_PROPERTIES: usize = 3_000; +const MFJS_MAX_TOTAL_ENUM_VALUES: usize = 1_000; const MFJS_ENUM_LENGTH_CHECK_THRESHOLD: usize = 250; -const MFJS_MAX_ENUM_STRING_LENGTH: usize = 7_500; +const MFJS_MAX_ENUM_STRING_LENGTH: usize = 75_000; const MFJS_MAX_SCHEMA_BYTES: usize = 120_000; const MFJS_MAX_STRUCTURAL_DEPTH: usize = 64; const MFJS_MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; @@ -3417,40 +3430,57 @@ mod kimi_tests { #[test] fn kimi_parameters_enforces_anyof_and_aggregate_resource_limits() { - let mut too_many_branches = json!({ - "type": "object", - "properties": { - "choice": { - "anyOf": (0..11) - .map(|_| json!({"type": "string"})) - .collect::>() + // Each boundary mirrors the walle validator's own default (the + // constant block cites `conf.go`, `DefaultValidatorConfig`, fetched + // 2026-11-05). The aligned value itself must ship — the limit exists + // to drop only what the server's own validator would also refuse, so + // an engine-local tightening cannot silently re-emerge here. + let branches_schema = |count: usize| { + json!({ + "type": "object", + "properties": { + "choice": { + "anyOf": (0..count) + .map(|_| json!({"type": "string"})) + .collect::>() + } } - } - }); + }) + }; + let mut at_any_of_budget = branches_schema(500); + sanitize_for_kimi_parameters(&mut at_any_of_budget) + .expect("500 anyOf branches is the aligned budget and must ship"); + let mut too_many_branches = branches_schema(501); assert_eq!( sanitize_for_kimi_parameters(&mut too_many_branches).unwrap_err(), KimiParameterSchemaError::ResourceLimitExceeded ); let string_property = || json!({"type": "string"}); - let mut root_properties = Map::new(); - for index in 0..51 { - root_properties.insert(format!("root_{index}"), string_property()); - } - let mut definition_properties = Map::new(); - for index in 0..50 { - definition_properties.insert(format!("definition_{index}"), string_property()); - } - let mut too_many_properties = json!({ - "type": "object", - "properties": Value::Object(root_properties), - "$defs": { - "Holder": { - "type": "object", - "properties": Value::Object(definition_properties) - } + let properties_schema = |root_count: usize, definition_count: usize| { + let mut root_properties = Map::new(); + for index in 0..root_count { + root_properties.insert(format!("root_{index}"), string_property()); } - }); + let mut definition_properties = Map::new(); + for index in 0..definition_count { + definition_properties.insert(format!("definition_{index}"), string_property()); + } + json!({ + "type": "object", + "properties": Value::Object(root_properties), + "$defs": { + "Holder": { + "type": "object", + "properties": Value::Object(definition_properties) + } + } + }) + }; + let mut at_property_budget = properties_schema(3000, 0); + sanitize_for_kimi_parameters(&mut at_property_budget) + .expect("3000 total properties is the aligned budget and must ship"); + let mut too_many_properties = properties_schema(1501, 1500); assert_eq!( sanitize_for_kimi_parameters(&mut too_many_properties).unwrap_err(), KimiParameterSchemaError::ResourceLimitExceeded @@ -3459,13 +3489,18 @@ mod kimi_tests { let enum_values = |start: usize, count: usize| { Value::Array((start..start + count).map(|value| json!(value)).collect()) }; - let mut too_many_enum_values = json!({ - "type": "object", - "properties": { - "first": {"type": "integer", "enum": enum_values(0, 250)}, - "second": {"type": "integer", "enum": enum_values(250, 251)} - } - }); + let enum_schema = |count: usize| { + json!({ + "type": "object", + "properties": { + "choice": {"type": "integer", "enum": enum_values(0, count)} + } + }) + }; + let mut at_enum_budget = enum_schema(1000); + sanitize_for_kimi_parameters(&mut at_enum_budget) + .expect("1000 enum values is the aligned budget and must ship"); + let mut too_many_enum_values = enum_schema(1001); assert_eq!( sanitize_for_kimi_parameters(&mut too_many_enum_values).unwrap_err(), KimiParameterSchemaError::ResourceLimitExceeded @@ -3474,30 +3509,49 @@ mod kimi_tests { #[test] fn kimi_parameters_enforces_depth_and_enum_text_limits() { - let mut too_deep = json!({"type": "string"}); - for index in (0..6).rev() { - let mut properties = Map::new(); - properties.insert(format!("level_{index}"), too_deep); - too_deep = json!({ - "type": "object", - "properties": Value::Object(properties) - }); - } + let nest_properties = |depth: usize| { + let mut nested = json!({"type": "string"}); + for index in (0..depth).rev() { + let mut properties = Map::new(); + properties.insert(format!("level_{index}"), nested); + nested = json!({ + "type": "object", + "properties": Value::Object(properties) + }); + } + nested + }; + let mut at_depth_budget = nest_properties(30); + sanitize_for_kimi_parameters(&mut at_depth_budget) + .expect("30 property levels is the aligned budget and must ship"); + let mut too_deep = nest_properties(31); assert_eq!( sanitize_for_kimi_parameters(&mut too_deep).unwrap_err(), KimiParameterSchemaError::ResourceLimitExceeded ); - let long_values = Value::Array( - (0..251) - .map(|index| Value::String(format!("private-enum-{index:04}-xxxxxxxxxxxxxx"))) - .collect(), - ); + // The encoded-length budget only arms past the check threshold, so + // both probes below carry enough values to reach it: 1000 values of + // 74 encoded chars total just under the aligned 75000 budget and + // must ship; the same count at 88 chars oversteps it and must not. + let long_enum = |padding: usize| { + Value::Array( + (0..1000) + .map(|index| { + Value::String(format!("private-enum-{index:04}-{}", "x".repeat(padding))) + }) + .collect(), + ) + }; + let mut at_enum_text_budget = json!({ + "type": "object", + "properties": {"choice": {"type": "string", "enum": long_enum(56)}} + }); + sanitize_for_kimi_parameters(&mut at_enum_text_budget) + .expect("74000 encoded enum chars sit inside the aligned budget"); let mut too_much_enum_text = json!({ "type": "object", - "properties": { - "choice": {"type": "string", "enum": long_values} - } + "properties": {"choice": {"type": "string", "enum": long_enum(70)}} }); assert_eq!( sanitize_for_kimi_parameters(&mut too_much_enum_text).unwrap_err(), @@ -3543,7 +3597,10 @@ mod kimi_tests { valid["properties"]["mismatched"]["default"], json!("private-default-value-3302") ); - assert_eq!(valid["properties"]["object_valued"]["default"], json!({"a": "b"})); + assert_eq!( + valid["properties"]["object_valued"]["default"], + json!({"a": "b"}) + ); assert_eq!(valid["properties"]["array_valued"]["default"], json!(["x"])); // A node that only carries `default` still fails the type-absence @@ -3869,6 +3926,15 @@ mod kimi_tests { 10, "the shipped union has exactly one branch per layout" ); + // The branch count is not the anyOf budget: the engine mirrors the + // walle validator's aligned default (500, see the constant block), so + // the sentinel only has to show the shipped union sits far below it + // and the whole pipeline still passes. + assert!( + shipped_branches.len() * 10 <= MFJS_MAX_ANY_OF_ITEMS, + "10 shipped branches must sit far below the anyOf budget {}", + MFJS_MAX_ANY_OF_ITEMS + ); let expected = branch_contract(shipped_branches); let mut parameters = shipped.clone(); @@ -3897,7 +3963,7 @@ mod kimi_tests { .expect("the slides.items union must survive normalization"); assert_eq!( branches.len(), - 10, + shipped_branches.len(), "normalization must not change the branch count" ); assert_eq!(branch_contract(branches), expected); From 1c7737a7106a6780206e683af56d04ad081af993 Mon Sep 17 00:00:00 2001 From: asto Date: Thu, 5 Nov 2026 13:12:53 +0800 Subject: [PATCH 13/20] fix(tui): land every scalar const through the same scalar-enum shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bool/null const failed the whole Moonshot tool closed while a declared bool enum was stripped with the tool surviving — two shapes of the same information taking opposite degradation paths. The const handling now admits every scalar: bool/null consts translate through the existing const→enum transform to enum:[v], and the existing unsupported-enum-value strip removes what MFJS does not support, so the node lives on through its declared type exactly like a declared bool enum does. Non-scalar consts (object/array) keep failing closed; type inference stays limited to the string/number kinds the strip never has to invent, so an untyped bool/null const fails MissingType once its translated enum is stripped, matching the module's existing enum-only-node doctrine. Signed-off-by: asto --- crates/tui/src/tools/schema_sanitize.rs | 89 +++++++++++++++++++++---- 1 file changed, 76 insertions(+), 13 deletions(-) diff --git a/crates/tui/src/tools/schema_sanitize.rs b/crates/tui/src/tools/schema_sanitize.rs index b248e14a91..67219d1794 100644 --- a/crates/tui/src/tools/schema_sanitize.rs +++ b/crates/tui/src/tools/schema_sanitize.rs @@ -1612,18 +1612,29 @@ fn normalize_kimi_compatibility( } if let Some(constant) = obj.remove("const") { - if !is_mfjs_enum_literal(&constant) { + // Every scalar const translates through the same const→enum landing: + // a bool/null const becomes `enum: [v]`, which the downstream + // unsupported-enum-value strip then removes exactly as it removes a + // declared bool enum — so the node survives on its declared type + // instead of dropping the whole tool. Non-scalar consts (object, + // array) have no scalar-enum shape and keep failing closed. + if !is_mfjs_scalar_const(&constant) { return Err(KimiParameterSchemaError::UnsupportedConstLiteral); } if !obj.contains_key("type") { - let inferred = mfjs_literal_kind(&constant) - .ok_or(KimiParameterSchemaError::UnsupportedConstLiteral)?; - let schema_type = match inferred { - MfjsLiteralKind::Integer => "integer", - MfjsLiteralKind::Number => "number", - MfjsLiteralKind::String => "string", - }; - obj.insert("type".to_string(), Value::String(schema_type.to_string())); + // Type inference stays limited to the MFJS-supported enum value + // kinds: the strip never invents a type the schema author did + // not declare, so an untyped bool/null const ends up with no + // type once its translated enum is stripped and fails + // MissingType in the validator. + if let Some(inferred) = mfjs_literal_kind(&constant) { + let schema_type = match inferred { + MfjsLiteralKind::Integer => "integer", + MfjsLiteralKind::Number => "number", + MfjsLiteralKind::String => "string", + }; + obj.insert("type".to_string(), Value::String(schema_type.to_string())); + } } if let Some(existing) = obj.get("enum") { let agrees = existing @@ -1694,8 +1705,13 @@ fn normalize_kimi_child_schemas(schema: &mut Value) -> Result<(), KimiParameterS Ok(()) } -fn is_mfjs_enum_literal(value: &Value) -> bool { - value.is_string() || value.is_number() +/// Whether a `const` value has a scalar-enum translation: strings and +/// numbers map onto MFJS-supported enum values, while bool/null translate to +/// an enum the unsupported-value strip then removes — the same landing a +/// declared bool enum already gets. Object/array consts have no scalar shape +/// and fail closed. +fn is_mfjs_scalar_const(value: &Value) -> bool { + value.is_string() || value.is_number() || value.is_boolean() || value.is_null() } /// Validate one fully normalized MFJS function-parameters schema. @@ -2616,10 +2632,12 @@ mod kimi_tests { #[test] fn kimi_parameters_rejects_unsafe_const_without_mutating_or_leaking() { + // Non-scalar consts have no scalar-enum translation, so they keep + // failing closed exactly as before. let mut schema = json!({ "type": "object", "properties": { - "private-toggle-8172": {"type": "boolean", "const": true} + "private-shape-8172": {"type": "object", "const": {"nested": true}} } }); let original = schema.clone(); @@ -2627,10 +2645,55 @@ mod kimi_tests { let error = sanitize_for_kimi_parameters(&mut schema).unwrap_err(); assert_eq!(error, KimiParameterSchemaError::UnsupportedConstLiteral); - assert!(!error.to_string().contains("private-toggle-8172")); + assert!(!error.to_string().contains("private-shape-8172")); assert_eq!(schema, original, "a rejected schema must remain reusable"); } + #[test] + fn kimi_parameters_scalarizes_bool_and_null_const_through_the_enum_strip() { + // A scalar const translates to the same-shaped scalar enum, and the + // existing unsupported-enum-value strip then removes what MFJS does + // not support — the same landing a declared `enum: [true]` already + // gets, so a bool/null const no longer drops the whole tool while a + // bool enum survives. The typed node lives on through its declared + // type; an untyped node has no type to fall back on and fails + // MissingType once its translated enum is stripped (the strip never + // invents a type the schema author did not declare). + let mut typed = json!({ + "type": "object", + "properties": { + "toggle": {"type": "boolean", "const": true}, + "absent": {"type": "null", "const": null} + } + }); + sanitize_for_kimi_parameters(&mut typed) + .expect("typed bool/null consts must scalarize instead of dropping the tool"); + assert_eq!(typed["properties"]["toggle"], json!({"type": "boolean"})); + assert_eq!(typed["properties"]["absent"], json!({"type": "null"})); + + let mut untyped = json!({ + "type": "object", + "properties": {"flag": {"const": false}} + }); + let error = sanitize_for_kimi_parameters(&mut untyped).unwrap_err(); + assert!( + matches!(error, KimiParameterSchemaError::MissingType), + "an untyped bool const has no type to survive on: {error:?}" + ); + + // Scalar string/number consts keep their documented enum landing. + let mut scalars = json!({ + "type": "object", + "properties": { + "kind": {"type": "string", "const": "var_handle"}, + "level": {"type": "integer", "const": 2} + } + }); + sanitize_for_kimi_parameters(&mut scalars).unwrap(); + assert_eq!(scalars["properties"]["kind"]["enum"], json!(["var_handle"])); + assert_eq!(scalars["properties"]["level"]["enum"], json!([2])); + } + #[test] fn kimi_parameters_validator_fails_closed_without_echoing_schema_values() { let mut schema = json!({ From aec831d53088267c930786d326838f9805882ffd Mon Sep 17 00:00:00 2001 From: asto Date: Thu, 5 Nov 2026 13:15:40 +0800 Subject: [PATCH 14/20] docs(tui): state the Moonshot strip/fail-closed policy where it is implemented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the degradation ruling next to the code it governs: lossless annotations and soft numeric bounds strip so the wire keeps the document's supported set, while form keywords with no document support that materially steer generation (pattern, format, prefixItems, ...) keep the baseline whole-tool removal — better a missing tool than an unguaranteed shape. The walle source's FutureKeywords bucket for pattern and friends is recorded as a corroborating source-comparison note only. Also annotate the 2^53-1 integer bound as a JSON interoperation ceiling (not a document clause) and collapse the duplicated probe-schema block in the every-route fail-closed test into one run with one assertion message — one pipeline serves every route, so the second identical run carried no information. Signed-off-by: asto --- crates/tui/src/tools/schema_sanitize.rs | 31 ++++++++++++++----------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/crates/tui/src/tools/schema_sanitize.rs b/crates/tui/src/tools/schema_sanitize.rs index 67219d1794..2cbf6a9d4f 100644 --- a/crates/tui/src/tools/schema_sanitize.rs +++ b/crates/tui/src/tools/schema_sanitize.rs @@ -1363,6 +1363,17 @@ fn sanitize_kimi_parameters_candidate( /// Remove the advisory keywords the MFJS specification does not support from /// every schema node of a Kimi parameters document. /// +/// The strip/fail-closed boundary this function embodies is the module's +/// degradation policy: keywords whose removal is lossless (annotations) or +/// loses only a soft constraint (numeric bounds) are stripped so the wire +/// still carries the document's supported set; form keywords with no +/// document support that materially steer generation (`pattern`, `format`, +/// `prefixItems`, …) keep the baseline whole-tool removal — better a missing +/// tool than an unguaranteed shape. Source-comparison note (not a shipping +/// basis): the walle implementation carries `pattern` and friends in its +/// `FutureKeywords` set — future, i.e. not currently validated — which is +/// consistent with, and only corroborates, keeping them off the wire. +/// /// The specification's supported keyword set is closed ("若官方规范中的某些 /// 概念、关键字或特性在本文档中未被提及,则表示当前 MFJS 尚未提供官方支持", /// , @@ -1785,6 +1796,8 @@ const MFJS_ENUM_LENGTH_CHECK_THRESHOLD: usize = 250; const MFJS_MAX_ENUM_STRING_LENGTH: usize = 75_000; const MFJS_MAX_SCHEMA_BYTES: usize = 120_000; const MFJS_MAX_STRUCTURAL_DEPTH: usize = 64; +// 2^53-1: the IEEE-754 double precision bound JSON numbers interoperate +// through — an interop ceiling, not a document clause. const MFJS_MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; struct MfjsValidationState<'a> { @@ -3092,22 +3105,12 @@ mod kimi_tests { fn probe_observed_keywords_fail_closed_on_every_route() { // Probe facts are observations only. No route may admit keywords the // MFJS specification does not list: the whole tool degrades with a - // projection warning instead of shipping an unguaranteed shape. - let mut schema = probe_observed_keyword_schema(); - let error = sanitize_for_kimi_parameters(&mut schema) - .expect_err("probe-observed keywords stay outside the platform whitelist"); - assert!( - matches!( - error, - KimiParameterSchemaError::UnsupportedKeyword - | KimiParameterSchemaError::UnsupportedNestedAllOf - ), - "the first out-of-document shape fails closed: {error:?}" - ); - + // projection warning instead of shipping an unguaranteed shape. One + // pipeline serves every Moonshot route, so one run of the composed + // probe schema covers all of them. let mut schema = probe_observed_keyword_schema(); let error = sanitize_for_kimi_parameters(&mut schema) - .expect_err("probe-observed keywords stay outside the document whitelist"); + .expect_err("probe-observed keywords stay outside every route's document whitelist"); assert!( matches!( error, From 67ec54347e83647e571bea905ce99cc51bade193 Mon Sep 17 00:00:00 2001 From: asto Date: Thu, 5 Nov 2026 13:45:41 +0800 Subject: [PATCH 15/20] fix(client): prefix digit-led Moonshot tool names to the documented pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kimi's function-call name pattern is ^[a-zA-Z_][a-zA-Z0-9-_]{0,127}$ (platform.kimi.ai tool-use documents, fetched 2026-11-05) — a leading digit sits outside the supported set, and to_api_tool_name only escapes characters the pattern cannot express at any position, so a tool named 3d_render went onto the wire outside the documented pattern. The Moonshot path now gives such API tool names a '_' prefix; forced tool_choice carries the same wire spelling so the request stays self-consistent and the dropped-tool guard compares like against like. Every other provider keeps the unmodified name — their documents carry no leading-character ban. Signed-off-by: asto --- crates/tui/src/client/chat.rs | 140 ++++++++++++++++++++++++++++++++-- 1 file changed, 134 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 05d01092b4..4a71f38eb0 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -1111,6 +1111,35 @@ fn mirror_minimax_reasoning_details_for_body(body: &mut Value, provider: ApiProv /// safety net. /// /// Returns the names of the dropped tools, in catalog order. +/// Give digit-led API tool names the `_` prefix the Kimi wire requires. +/// +/// Kimi's function-call `name` pattern is `^[a-zA-Z_][a-zA-Z0-9-_]{0,127}$` +/// (platform.kimi.ai tool-use documents, fetched 2026-11-05): a leading +/// digit sits outside the supported set, and `to_api_tool_name` only escapes +/// characters the pattern cannot express at any position. Only the Moonshot +/// path applies this — no other provider's documents carry the +/// leading-character ban. +fn prefix_digit_led_tool_names_for_moonshot(chat_tools: &mut [Value]) { + for tool in chat_tools.iter_mut() { + let Some(function) = tool.get_mut("function").and_then(Value::as_object_mut) else { + continue; + }; + let digit_led = function + .get("name") + .and_then(Value::as_str) + .and_then(|name| name.as_bytes().first()) + .is_some_and(u8::is_ascii_digit); + if digit_led + && let Some(name) = function + .get("name") + .and_then(Value::as_str) + .map(str::to_owned) + { + function.insert("name".into(), json!(format!("_{name}"))); + } + } +} + fn sanitize_moonshot_chat_tools(chat_tools: &mut Vec) -> Vec { let mut dropped = Vec::new(); chat_tools.retain_mut(|tool| { @@ -1242,6 +1271,7 @@ pub(crate) fn build_chat_wire_body( // whose parameters cannot pass MFJS validation so one incompatible // tool never sinks the whole request. if matches!(provider, crate::config::ApiProvider::Moonshot) { + prefix_digit_led_tool_names_for_moonshot(&mut chat_tools); omitted_tool_names = sanitize_moonshot_chat_tools(&mut chat_tools); } // xAI rejects a parameters root that is not a plain object schema @@ -1281,15 +1311,41 @@ pub(crate) fn build_chat_wire_body( } if should_send_tool_choice_for_chat(provider, request.reasoning_effort.as_deref()) && let Some(choice) = request.tool_choice.as_ref() - && let Some(mapped) = map_tool_choice_for_chat(choice) + && let Some(mut mapped) = map_tool_choice_for_chat(choice) { if matches!(provider, crate::config::ApiProvider::Moonshot) - && let Some(name) = mapped.pointer("/function/name").and_then(Value::as_str) - && omitted_tool_names.iter().any(|omitted| omitted == name) + && let Some(forced) = mapped + .pointer("/function/name") + .and_then(Value::as_str) + .map(str::to_owned) { - bail!( - "Moonshot cannot force tool '{name}' because its input schema is incompatible with this route" - ); + // A forced call must name the same wire spelling the tools array + // carries (see `prefix_digit_led_tool_names_for_moonshot`), both + // so the documented pattern holds and so the dropped-tool check + // below compares like against like. + let digit_led = forced.as_bytes().first().is_some_and(u8::is_ascii_digit); + let wire_name = if digit_led { + format!("_{forced}") + } else { + forced + }; + if wire_name + != *mapped + .pointer("/function/name") + .and_then(Value::as_str) + .unwrap_or_default() + && let Some(function) = mapped.get_mut("function").and_then(Value::as_object_mut) + { + function.insert("name".into(), json!(wire_name.clone())); + } + if omitted_tool_names + .iter() + .any(|omitted| omitted == &wire_name) + { + bail!( + "Moonshot cannot force tool '{wire_name}' because its input schema is incompatible with this route" + ); + } } if body.get("tools").is_some() { body["tool_choice"] = mapped; @@ -6576,6 +6632,78 @@ mod alias_thinking_detection_tests { assert!(body.get("top_p").is_none()); } + fn digit_led_name_probe_tool(name: &str) -> crate::models::Tool { + crate::models::Tool { + tool_type: None, + name: name.to_string(), + description: "probe".to_string(), + input_schema: json!({ + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"] + }), + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + } + } + + #[test] + fn moonshot_wire_prefixes_digit_led_tool_names_to_the_documented_pattern() { + // Kimi's function-call name pattern is + // `^[a-zA-Z_][a-zA-Z0-9-_]{0,127}$` (platform.kimi.ai tool-use + // documents, fetched 2026-11-05): a leading digit sits outside the + // supported set and `to_api_tool_name` only escapes characters the + // pattern cannot express anywhere. The Moonshot path gives such a + // name a `_` prefix; every other provider keeps the name unmodified. + let mut request = sampling_probe_request(crate::config::DEFAULT_MOONSHOT_MODEL); + request.tools = Some(vec![ + digit_led_name_probe_tool("3d_render"), + digit_led_name_probe_tool("mcp__server__tool"), + ]); + request.tool_choice = Some(json!({"type": "tool", "name": "3d_render"})); + + let body = crate::client::chat::build_chat_wire_body( + &request, + ApiProvider::Moonshot, + crate::config::DEFAULT_MOONSHOT_BASE_URL, + false, + ) + .expect("Moonshot wire body"); + + let tools = body.body["tools"].as_array().expect("tools on the wire"); + assert_eq!(tools[0]["function"]["name"], json!("_3d_render")); + assert_eq!( + tools[1]["function"]["name"], + json!("mcp__server__tool"), + "names already inside the pattern stay untouched" + ); + assert_eq!( + body.body["tool_choice"]["function"]["name"], + json!("_3d_render"), + "a forced call must name the same wire spelling the tools array carries" + ); + + // A non-Moonshot provider has no documented leading-character ban and + // keeps the raw name. + let mut request = sampling_probe_request(crate::config::DEFAULT_MOONSHOT_MODEL); + request.tools = Some(vec![digit_led_name_probe_tool("3d_render")]); + let body = crate::client::chat::build_chat_wire_body( + &request, + ApiProvider::Openai, + "https://api.openai.com/v1", + false, + ) + .expect("OpenAI wire body"); + assert_eq!( + body.body["tools"][0]["function"]["name"], + json!("3d_render"), + "other providers are not touched" + ); + } + #[test] fn kimi_code_fixed_sampling_covers_every_model_id_on_the_route() { // Observation record (2026-11-04 live probe, GET /models + per-model From 8e2882e3d499973d9552aac7c69507a32276527b Mon Sep 17 00:00:00 2001 From: asto Date: Thu, 5 Nov 2026 13:56:10 +0800 Subject: [PATCH 16/20] test(tui): re-export the pptx fixture from the current parent server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped fixture had drifted twice from the parent repo's pptx server.py: the slides description still carried the pre-177dbce6 '只给 title 会得到空白页' wording, and the agenda/end required groups predated the 2026-11-05 required audit (agenda now [layout, items], end [layout] — their empty titles render the 「目录」/「谢谢」 business fallbacks, so title is optional; the other eight branches are unchanged, cover/section/image layout+title and the six body layouts layout+title+body group). The fixture is regenerated byte-exactly from the current parent server.py by AST-evaluating its TOOL_DEFS literal — validated by regenerating from the pre-change revision and diffing against the stale fixture, where the description line was the only delta. The shipped-schema test pins every per-branch required group so a future drift fails loudly, keeping the structural, enum, required, and wire-keyword matrix assertions. Signed-off-by: asto --- crates/tui/src/tools/schema_sanitize.rs | 28 +++++++++++++++++++ .../tests/fixtures/pptx_make_pptx_schema.json | 10 +++---- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/tools/schema_sanitize.rs b/crates/tui/src/tools/schema_sanitize.rs index 2cbf6a9d4f..a9bf260e1b 100644 --- a/crates/tui/src/tools/schema_sanitize.rs +++ b/crates/tui/src/tools/schema_sanitize.rs @@ -4003,6 +4003,34 @@ mod kimi_tests { ); let expected = branch_contract(shipped_branches); + // Per-branch required groups pinned to the parent-side server + // contract (2026-11-05 required audit, single standard: only an + // empty body group is a business exception). agenda renders the + // 「目录」 fallback and end the 「谢谢」 fallback, so their titles are + // optional; cover/section/image stay layout+title; the six body + // layouts keep layout+title plus their body group. + let required_of = |layout: &str| -> Vec { + expected + .iter() + .find(|(name, _)| name == layout) + .map(|(_, required)| required.clone()) + .unwrap_or_else(|| panic!("{layout} branch missing from the shipped union")) + }; + assert_eq!(required_of("agenda"), vec!["layout", "items"]); + assert_eq!(required_of("end"), vec!["layout"]); + assert_eq!(required_of("cover"), vec!["layout", "title"]); + assert_eq!(required_of("section"), vec!["layout", "title"]); + assert_eq!(required_of("image"), vec!["layout", "title"]); + for layout in ["bullets", "two_col", "kpi", "chart", "table"] { + let required = required_of(layout); + assert!( + required.contains(&"layout".to_string()) + && required.contains(&"title".to_string()) + && required.len() >= 3, + "{layout} keeps layout+title plus its body group: {required:?}" + ); + } + let mut parameters = shipped.clone(); let note = sanitize_for_kimi_parameters(&mut parameters) .expect("the shipped make_pptx schema must pass Moonshot normalization"); diff --git a/crates/tui/tests/fixtures/pptx_make_pptx_schema.json b/crates/tui/tests/fixtures/pptx_make_pptx_schema.json index 7f3fc134db..001af9ab81 100644 --- a/crates/tui/tests/fixtures/pptx_make_pptx_schema.json +++ b/crates/tui/tests/fixtures/pptx_make_pptx_schema.json @@ -12,7 +12,7 @@ }, "slides": { "type": "array", - "description": "deck 的 slides 数组。每页一个对象,`layout` 选版式,并**必须按版式填正文字段**(只给 title 会得到空白页!)。每版式的必填字段已写进对应分支的 required:\n- cover: {title}(subtitle/date 可选)\n- agenda: {title, items} 章节条目数组\n- section: {title} ← 章节分隔页\n- bullets: {title, bullets} 要点数组\n- two_col: {title, left, right}(leftTitle/rightTitle 可选)\n- kpi: {title, items} 最多4个大数字\n- chart: {title, chartType?, categories, series}\n- table: {title, headers, rows}\n- image: {title}(image/bullets/caption 可选)\n- end: {title}(subtitle 可选) ← 结尾页\n正文要有真实内容/数据,不要只放标题。", + "description": "deck 的 slides 数组。每页一个对象,`layout` 选版式,并**按版式填该分支required 的正文字段**(agenda/bullets/two_col/kpi/chart/table 缺正文字段会得到空白正文页)。每版式的必填字段已写进对应分支的 required:\n- cover: {title}(subtitle/date 可选)\n- agenda: {items} 章节条目数组;title 可选,缺省「目录」\n- section: {title} ← 章节分隔页\n- bullets: {title, bullets} 要点数组\n- two_col: {title, left, right}(leftTitle/rightTitle 可选)\n- kpi: {title, items} 最多4个大数字\n- chart: {title, chartType?, categories, series}\n- table: {title, headers, rows}\n- image: {title}(image/bullets/caption 可选)\n- end: layout 即完整(subtitle 可选;标题缺省「谢谢」) ← 结尾页\n正文版式(agenda/bullets/two_col/kpi/chart/table)要有真实内容/数据,不要只放标题。", "items": { "description": "一页 = 一个版式分支:layout 的单值 enum 选中分支,各分支 required 列出该版式必填字段", "anyOf": [ @@ -47,7 +47,7 @@ }, { "type": "object", - "description": "目录页:必填 items(章节条目数组,最多 6 条)", + "description": "目录页:必填 items(章节条目数组,最多 6 条);title 可选,缺省「目录」", "properties": { "layout": { "type": "string", @@ -87,7 +87,6 @@ }, "required": [ "layout", - "title", "items" ] }, @@ -372,7 +371,7 @@ }, { "type": "object", - "description": "结尾页:必填 title(如「谢谢」);subtitle 可选", + "description": "结尾页:layout 即完整,标题缺省「谢谢」;subtitle 可选", "properties": { "layout": { "type": "string", @@ -391,8 +390,7 @@ } }, "required": [ - "layout", - "title" + "layout" ] } ] From 5f5eca337e12748bc88021088d13a2959635fb8a Mon Sep 17 00:00:00 2001 From: asto Date: Fri, 6 Nov 2026 00:28:00 +0800 Subject: [PATCH 17/20] fix(client): close the Moonshot digit-led tool-name loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Moonshot chat path prefixes a digit-led registry tool name with `_` to satisfy Kimi's documented `name` pattern, but the loop only ran outbound: the response decoder deliberately keeps the wire spelling, no layer mapped `_3d_render` back to `3d_render`, and every model call for such a tool died at dispatch with "tool not available" — the tools array kept advertising the name, so the model would retry the same spelling until the turn ended. Two halves now close the loop: - Engine dispatch: after every exact and alias lookup misses, `resolve_tool_definition` falls back to the unprefixed name for the exact `_` shape the prefix produces. A genuinely underscore-led name is never stripped, and a registry holding both spellings still resolves exactly (the fallback only fires after the exact lookups missed), so approval and policy gates see the canonical registry name. - Replay: assistant tool_calls persist the registry spelling, so the Moonshot message builder re-applies the wire prefix on replayed `tool_calls` (shared `prefix_digit_led_function_name` helper with the tools-array pass). The history now matches the tools array and stays inside the documented pattern; pre-fixture histories that already carry `_` pass through untouched. Also pins the hand-copied sampling-route matcher to the config-crate reference on the shared host (the twin covers the overseas host until PR #137 widens the shared matcher; that assertion is what flips when it lands), and restores the doc comment that `sanitize_moonshot_chat_tools` had lost when the prefix function was inserted between the comment and its function. No-Issue: completes the digit-led fix in this PR's own series; the repository has issues disabled. Signed-off-by: asto --- crates/tui/src/client/chat.rs | 215 ++++++++++++++++++++---- crates/tui/src/core/engine/turn_loop.rs | 112 ++++++++++++ 2 files changed, 298 insertions(+), 29 deletions(-) diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 4a71f38eb0..de014b4643 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -1111,35 +1111,6 @@ fn mirror_minimax_reasoning_details_for_body(body: &mut Value, provider: ApiProv /// safety net. /// /// Returns the names of the dropped tools, in catalog order. -/// Give digit-led API tool names the `_` prefix the Kimi wire requires. -/// -/// Kimi's function-call `name` pattern is `^[a-zA-Z_][a-zA-Z0-9-_]{0,127}$` -/// (platform.kimi.ai tool-use documents, fetched 2026-11-05): a leading -/// digit sits outside the supported set, and `to_api_tool_name` only escapes -/// characters the pattern cannot express at any position. Only the Moonshot -/// path applies this — no other provider's documents carry the -/// leading-character ban. -fn prefix_digit_led_tool_names_for_moonshot(chat_tools: &mut [Value]) { - for tool in chat_tools.iter_mut() { - let Some(function) = tool.get_mut("function").and_then(Value::as_object_mut) else { - continue; - }; - let digit_led = function - .get("name") - .and_then(Value::as_str) - .and_then(|name| name.as_bytes().first()) - .is_some_and(u8::is_ascii_digit); - if digit_led - && let Some(name) = function - .get("name") - .and_then(Value::as_str) - .map(str::to_owned) - { - function.insert("name".into(), json!(format!("_{name}"))); - } - } -} - fn sanitize_moonshot_chat_tools(chat_tools: &mut Vec) -> Vec { let mut dropped = Vec::new(); chat_tools.retain_mut(|tool| { @@ -1188,6 +1159,65 @@ fn sanitize_moonshot_chat_tools(chat_tools: &mut Vec) -> Vec { dropped } +/// Give digit-led API tool names the `_` prefix the Kimi wire requires. +/// +/// Kimi's function-call `name` pattern is `^[a-zA-Z_][a-zA-Z0-9-_]{0,127}$` +/// (platform.kimi.ai tool-use documents, fetched 2026-11-05): a leading +/// digit sits outside the supported set, and `to_api_tool_name` only escapes +/// characters the pattern cannot express at any position. Only the Moonshot +/// path applies this — no other provider's documents carry the +/// leading-character ban. +fn prefix_digit_led_tool_names_for_moonshot(chat_tools: &mut [Value]) { + for tool in chat_tools.iter_mut() { + let Some(function) = tool.get_mut("function").and_then(Value::as_object_mut) else { + continue; + }; + prefix_digit_led_function_name(function); + } +} + +/// Prefix one `function` object's `name` in place when it starts with an +/// ASCII digit. Shared by the tools array and the replayed assistant +/// `tool_calls`, which must carry the same wire spelling; dispatch resolves +/// the prefixed spelling back to the registry name (see the digit-led wire +/// recovery in the engine's tool resolution). +fn prefix_digit_led_function_name(function: &mut serde_json::Map) { + let digit_led = function + .get("name") + .and_then(Value::as_str) + .and_then(|name| name.as_bytes().first()) + .is_some_and(u8::is_ascii_digit); + if digit_led + && let Some(name) = function + .get("name") + .and_then(Value::as_str) + .map(str::to_owned) + { + function.insert("name".into(), json!(format!("_{name}"))); + } +} + +/// Re-prefix digit-led names on replayed assistant `tool_calls` for Moonshot. +/// +/// Assistant turns persist the registry spelling (dispatch resolves the +/// digit-led wire prefix back before executing), so the history carries +/// `3d_render` while the tools array advertises `_3d_render`. Re-applying +/// the prefix here keeps the replayed turns consistent with the tools array +/// and inside Kimi's documented `name` pattern. Idempotent: names already +/// carrying the wire prefix are not digit-led and stay untouched. +fn prefix_digit_led_tool_call_names_for_moonshot(messages: &mut [Value]) { + for message in messages.iter_mut() { + let Some(calls) = message.get_mut("tool_calls").and_then(Value::as_array_mut) else { + continue; + }; + for call in calls.iter_mut() { + if let Some(function) = call.get_mut("function").and_then(Value::as_object_mut) { + prefix_digit_led_function_name(function); + } + } + } +} + /// The final Chat Completions wire payload for one request. /// /// Produced by [`build_chat_wire_body`], the single place where a @@ -1962,6 +1992,9 @@ impl<'a> PromptBuilder<'a> { if !is_exact_google_chat_route(provider, base_url) { strip_google_tool_call_extra_content(&mut messages); } + if provider == ApiProvider::Moonshot { + prefix_digit_led_tool_call_names_for_moonshot(&mut messages); + } messages } @@ -6824,6 +6857,130 @@ mod alias_thinking_detection_tests { } } + #[test] + fn moonshot_replay_prefixes_digit_led_assistant_tool_call_names() { + use crate::client::chat::build_chat_messages_for_request_and_provider; + use crate::models::{ContentBlock, Message, MessageRequest, Role}; + + // Assistant turns persist the registry spelling: dispatch resolves + // the digit-led wire prefix back before executing (see the engine's + // digit-led wire recovery). The replay must re-apply the prefix so + // the history matches the tools array + // (`moonshot_wire_prefixes_digit_led_tool_names_to_the_documented_pattern`) + // and stays inside Kimi's documented `name` pattern. + let request = MessageRequest { + model: crate::config::DEFAULT_MOONSHOT_MODEL.to_string(), + messages: vec![ + Message { + role: Role::Assistant, + content: vec![ContentBlock::ToolUse { + id: "call-probe-1".to_string(), + name: "3d_render".to_string(), + input: json!({"path": "scene.glb"}), + caller: None, + thought_signature: None, + }], + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "call-probe-1".to_string(), + content: "rendered".to_string(), + is_error: None, + content_blocks: None, + }], + }, + Message { + role: Role::Assistant, + content: vec![ContentBlock::ToolUse { + id: "call-probe-2".to_string(), + // A history entry that already carries the wire + // prefix (pre-fixture sessions) is not digit-led + // and must pass through untouched — idempotence. + name: "_3d_render".to_string(), + input: json!({"path": "scene.glb"}), + caller: None, + thought_signature: None, + }], + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "call-probe-2".to_string(), + content: "rendered".to_string(), + is_error: None, + content_blocks: None, + }], + }, + ], + max_tokens: 16, + system: None, + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: None, + stream: None, + temperature: None, + top_p: None, + }; + + let messages = + build_chat_messages_for_request_and_provider(&request, ApiProvider::Moonshot); + assert_eq!( + messages[0]["tool_calls"][0]["function"]["name"], + json!("_3d_render"), + "the registry spelling is re-prefixed on the wire" + ); + assert_eq!( + messages[2]["tool_calls"][0]["function"]["name"], + json!("_3d_render"), + "a stored wire spelling passes through unchanged" + ); + + // Other providers keep the registry spelling on the wire: the + // documented leading-character ban is Moonshot-only. + let messages = build_chat_messages_for_request_and_provider(&request, ApiProvider::Openai); + assert_eq!(messages[0]["tool_calls"][0]["function"]["name"], json!("3d_render")); + } + + #[test] + fn kimi_code_sampling_route_stays_in_sync_with_the_config_matcher_on_the_shared_host() { + use super::kimi_code_sampling_route; + + // The sampling strip deliberately covers both membership hosts while + // the config-crate matcher is still .com-only (widening it belongs to + // in-flight PR #137). Until that lands, pin the twin's semantics to + // the reference on the shared host so a config-side change cannot + // silently diverge from the hand-copied matcher. + for url in [ + "https://api.kimi.com/coding/v1", + "https://api.kimi.com/coding/v1/", + "HTTPS://API.KIMI.COM/coding/v1", + "https://api.kimi.com/CODING/V1", + "https://api.kimi.com/coding/v2", + "https://api.kimi.com/coding", + "http://api.kimi.com/coding/v1", + "https://api.kimi.com:8443/coding/v1", + "https://api.kimi.com/coding/v1//", + "https://api.kimi.io/coding/v1", + "https://example.com/coding/v1", + ] { + assert_eq!( + kimi_code_sampling_route(url), + crate::config::moonshot_base_url_is_exact_kimi_code(url), + "twin drift on {url}" + ); + } + // The overseas host is the deliberate twin-only widening until the + // shared matcher catches up; this assertion is what flips when the + // widening lands. + assert!(kimi_code_sampling_route("https://api.kimi.ai/coding/v1")); + assert!(!crate::config::moonshot_base_url_is_exact_kimi_code( + "https://api.kimi.ai/coding/v1" + )); + } + #[test] fn kimi_code_fixed_sampling_covers_the_overseas_api_kimi_ai_endpoint() { // The official Kimi Code documentation lists two regional endpoints diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index c01e675186..ecf1c5a8a0 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -7515,6 +7515,28 @@ fn resolve_tool_definition<'a>( } } + // Moonshot digit-led wire recovery: the Moonshot request path prefixes a + // digit-led registry name with `_` to satisfy Kimi's documented `name` + // pattern, and the response decoder deliberately leaves that prefix in + // place — a bare underscore strip here would corrupt genuinely + // underscore-led names. Only when every exact and alias lookup above + // missed and the wire spelling carries the exact `_` shape the + // prefix produces does dispatch fall back to the unprefixed name, so a + // registry that really contains both spellings still resolves exactly. + if tool_def.is_none() + && tool_name.len() > 1 + && tool_name.starts_with('_') + && tool_name.as_bytes()[1].is_ascii_digit() + { + let unprefixed = tool_name[1..].to_string(); + if let Some(def) = tool_catalog.iter().find(|def| def.name.as_str() == unprefixed) { + tool_def = Some(def); + *tool_name = unprefixed; + } else if tool_registry.is_some_and(|registry| registry.get(&unprefixed).is_some()) { + *tool_name = unprefixed; + } + } + tool_def } @@ -8542,6 +8564,96 @@ mod tests { } } + /// Minimal registry tool with a caller-chosen name, for wire-name tests. + struct NamedProbeTool { + name: &'static str, + } + + #[async_trait::async_trait] + impl crate::tools::spec::ToolSpec for NamedProbeTool { + fn name(&self) -> &str { + self.name + } + + fn description(&self) -> &str { + "probe" + } + + fn input_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"] + }) + } + + fn capabilities(&self) -> Vec { + vec![crate::tools::spec::ToolCapability::ReadOnly] + } + + async fn execute( + &self, + _input: serde_json::Value, + _context: &crate::tools::spec::ToolContext, + ) -> Result { + Ok(crate::tools::spec::ToolResult { + content: "probe ok".to_string(), + success: true, + metadata: None, + }) + } + } + + #[test] + fn moonshot_digit_led_wire_name_resolves_to_the_registry_tool() { + // The Moonshot chat path advertises a digit-led registry tool as + // `_3d_render` on the wire (Kimi's documented name pattern), the + // response decoder deliberately keeps that spelling, and dispatch + // must fall back to the unprefixed registry name — otherwise the + // model's calls can never dispatch. + let tmp = tempfile::tempdir().expect("tempdir"); + let context = crate::tools::spec::ToolContext::new(tmp.path().to_path_buf()); + let mut registry = crate::tools::ToolRegistry::new(context); + registry.register(std::sync::Arc::new(NamedProbeTool { + name: "3d_render", + })); + let catalog = registry.to_api_tools(); + + let mut name = "_3d_render".to_string(); + let tool_def = resolve_tool_definition(&mut name, &catalog, Some(®istry)); + assert!(tool_def.is_some(), "digit-led wire name must resolve"); + assert_eq!(name, "3d_render"); + assert!(registry.get(&name).is_some()); + + // A genuinely underscore-led name resolves exactly as itself; the + // fallback must never strip it. + let mut name = "_flat_tool".to_string(); + assert!(resolve_tool_definition(&mut name, &catalog, Some(®istry)).is_none()); + assert_eq!(name, "_flat_tool"); + } + + #[test] + fn moonshot_digit_led_collision_resolves_to_the_exact_spelling() { + // When the registry contains both spellings, the wire name + // `_3d_render` must keep resolving to the underscore twin — the + // fallback only fires after every exact lookup missed. + let tmp = tempfile::tempdir().expect("tempdir"); + let context = crate::tools::spec::ToolContext::new(tmp.path().to_path_buf()); + let mut registry = crate::tools::ToolRegistry::new(context); + registry.register(std::sync::Arc::new(NamedProbeTool { + name: "3d_render", + })); + registry.register(std::sync::Arc::new(NamedProbeTool { + name: "_3d_render", + })); + let catalog = registry.to_api_tools(); + + let mut name = "_3d_render".to_string(); + let tool_def = resolve_tool_definition(&mut name, &catalog, Some(®istry)); + assert!(tool_def.is_some()); + assert_eq!(name, "_3d_render"); + } + #[tokio::test] async fn saved_legacy_file_and_bash_calls_keep_their_handlers_and_inputs() { let tmp = tempfile::tempdir().expect("tempdir"); From 6d37a52cf817336c5fa2d42a580d401643163c1c Mon Sep 17 00:00:00 2001 From: asto Date: Fri, 6 Nov 2026 00:28:30 +0800 Subject: [PATCH 18/20] fix(tools): keep empty MFJS enums fail-closed and disclose dropped groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fail-open corners found by the fresh review of this series, each now closed with a pinning test: - `strip_mfjs_unsupported_enum_values` removed an empty `enum: []`. An empty enum accepts nothing, so removing it silently widened an unsatisfiable constraint into "anything goes" — exactly the simplification of a malformed keyword the strip's own doc comment and inline comment both forswear ("an empty or non-array enum is left for the validator to fail closed"). The homogeneous check now treats the empty array as vacuously homogeneous, the keyword survives the strip, and the validator's non-empty rule fails the whole tool. A non-array `enum` takes the same road; both shapes are pinned. - `root_composition_constraint_note` only fired at two or more distinct dropped required groups, so a flatten that relaxed a single required group (one of two branches required it) loosened the contract with zero model-facing disclosure. The note now fires for any non-empty dropped group; the three-group exact-text pins are unchanged. Also adds the missing boundary probes found unpinned by mutation testing and the review lanes: the exact 75000 encoded-enum-char budget (previous probes straddled at 74000/88000), the document-wide total enum-value accumulation across two nodes (walle counts per node — the constant block now discloses that scope caveat alongside the threshold one), the 120000-byte whole-document budget (previously unprobed), and the engine-local 64-entry structural-depth guard. The `remove_empty_mfjs_items_schemas` doc now attributes nested `allOf` and tuple-form `items` failures to `normalize_kimi_compatibility`, where they actually fire, instead of validation. No-Issue: review follow-ups for this PR's own series; the repository has issues disabled. Signed-off-by: asto --- crates/tui/src/tools/schema_sanitize.rs | 207 ++++++++++++++++++++++-- 1 file changed, 198 insertions(+), 9 deletions(-) diff --git a/crates/tui/src/tools/schema_sanitize.rs b/crates/tui/src/tools/schema_sanitize.rs index a9bf260e1b..d2e057a786 100644 --- a/crates/tui/src/tools/schema_sanitize.rs +++ b/crates/tui/src/tools/schema_sanitize.rs @@ -415,7 +415,11 @@ fn root_composition_constraint_note(obj: &Map) -> Option let mut groups: Vec = items.iter().filter_map(required_group_label).collect(); groups.sort(); groups.dedup(); - if groups.len() >= 2 { + // Every dropped group must be disclosed, including a single one: + // with one distinct label the flatten still relaxed the original + // constraint (e.g. only one of two branches required it), and + // suppressing the note there used to hide the loosening entirely. + if !groups.is_empty() { return Some(format!( "{prefix} of these parameter groups must be provided: {}.", groups.join(" | ") @@ -1137,6 +1141,34 @@ mod tests { Some("Exactly one of these parameter groups must be provided: `a` + `b` | `c`.") ); } + + #[test] + fn responses_constraint_note_covers_a_single_dropped_group() { + // One distinct dropped group is still a dropped constraint: when + // only one of the branches carries a required group, the flatten + // relaxes that requirement and the note must fire — it used to be + // suppressed below the two-group gate, hiding the loosening + // entirely. + let mut schema = json!({ + "anyOf": [ + { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"] + }, + {"type": "object"} + ] + }); + + let note = sanitize_for_responses(&mut schema); + + assert_eq!(schema["type"], "object"); + assert!(schema.get("anyOf").is_none()); + assert_eq!( + note.as_deref(), + Some("At least one of these parameter groups must be provided: `path`.") + ); + } } /// Normalize a tool's function schema for Kimi / Moonshot API compatibility. @@ -1480,10 +1512,13 @@ fn strip_mfjs_unsupported_enum_values(value: &mut Value) { if let Some(values) = obj.get("enum").and_then(Value::as_array) { // `None` kinds (non-scalars) or differing kinds make the enum // unsupported; an empty array stays for the validator to fail - // closed, per the doc comment above. + // closed, per the doc comment above. The all() below is vacuously + // true for an empty array — `kinds[0]` is never evaluated — so an + // empty enum is kept and the validator's non-empty check rejects + // the whole tool instead of this strip silently widening an + // unsatisfiable constraint into "anything goes". let kinds: Option> = values.iter().map(mfjs_literal_kind).collect(); - let homogeneous = kinds - .is_some_and(|kinds| !kinds.is_empty() && kinds.iter().all(|kind| kind == &kinds[0])); + let homogeneous = kinds.is_some_and(|kinds| kinds.iter().all(|kind| kind == &kinds[0])); if !homogeneous { obj.shift_remove("enum"); } @@ -1503,9 +1538,11 @@ fn strip_mfjs_unsupported_enum_values(value: &mut Value) { /// `patternProperties`, `not`, `if`/`then`/`else`, `propertyNames`, /// `unevaluatedProperties`, `unevaluatedItems`, nested `allOf`, tuple-form /// `items`) need no visit: the keyword itself fails closed — -/// `UnsupportedKeyword`, `UnsupportedNestedAllOf` for a nested `allOf`, or -/// `InvalidSchemaNode` for tuple-form `items` — before a stranded empty -/// `items` node inside one could matter. `dependentSchemas` is the one +/// `UnsupportedKeyword` at validation for the unlisted keywords, +/// `UnsupportedNestedAllOf` for a nested `allOf` and `InvalidSchemaNode` +/// for tuple-form `items` already in `normalize_kimi_compatibility` — +/// before a stranded empty `items` node inside one could matter. +/// `dependentSchemas` is the one /// exception in shape: `strip_dependent_keywords` removes it (with its /// subtree) rather than failing closed, so it cannot strand one either. /// @@ -1786,8 +1823,12 @@ pub fn validate_mfjs_parameters(parameters: &Value) -> Result<(), KimiParameterS // by an engine-local tightening. The enum-length check threshold stays // engine-local: applying the same MaxEnumStringLength budget from 250 values // up is a stricter checking cadence than walle's 2500-value threshold, never -// a wider budget. MFJS_MAX_STRUCTURAL_DEPTH is engine-only — a recursion -// guard for this walker, not a walle budget. +// a wider budget. Scope caveat, same spirit: walle's MaxEnumItems is +// per-node (`len(enum) > MaxEnumItems` in keyword_validators.go) while +// MFJS_MAX_TOTAL_ENUM_VALUES accumulates across the whole document, so a +// multi-node document can be dropped here at a total the server would +// accept node-by-node. MFJS_MAX_STRUCTURAL_DEPTH is engine-only — a +// recursion guard for this walker, not a walle budget. const MFJS_MAX_ANY_OF_ITEMS: usize = 500; const MFJS_MAX_OBJECT_DEPTH: usize = 30; const MFJS_MAX_TOTAL_PROPERTIES: usize = 3_000; @@ -3322,6 +3363,39 @@ mod kimi_tests { assert_eq!(schema["properties"]["ratio"]["enum"], json!([0.5, 1.5])); } + #[test] + fn kimi_parameters_empty_enum_fails_closed_in_the_validator() { + // An empty enum accepts nothing, so stripping it would silently + // widen an unsatisfiable constraint into "anything goes". The strip + // keeps the keyword (the homogeneous check is vacuously true for an + // empty array) and the validator's non-empty rule fails the whole + // tool — the documented "malformed keyword is a schema bug" policy. + let mut schema = json!({ + "type": "object", + "properties": {"choice": {"type": "string", "enum": []}}, + "required": ["choice"] + }); + let error = sanitize_for_kimi_parameters(&mut schema) + .expect_err("an empty enum is malformed and must fail closed"); + assert!( + matches!(error, KimiParameterSchemaError::InvalidKeywordValue), + "empty enum drops via the validator: {error:?}" + ); + + // A non-array `enum` takes the same road: kept by the strip, then + // failed closed by the validator. + let mut malformed = json!({ + "type": "object", + "properties": {"choice": {"type": "string", "enum": "fast"}} + }); + let error = sanitize_for_kimi_parameters(&mut malformed) + .expect_err("a non-array enum is malformed and must fail closed"); + assert!( + matches!(error, KimiParameterSchemaError::InvalidKeywordValue), + "non-array enum drops via the validator: {error:?}" + ); + } + #[test] fn kimi_parameters_fail_closed_shapes_stay_dropped() { // The body's fail-closed enumeration, each pinned so a validator @@ -3625,6 +3699,121 @@ mod kimi_tests { ); } + #[test] + fn kimi_parameters_enum_text_budget_boundary_is_exact() { + // The probes above straddle the encoded-length budget (74000 under, + // 88000 over); this pins the exact boundary — 1000 values of exactly + // 75 encoded chars total 75000 and ship, one char more each totals + // 76000 and must not. A value edit that moves the budget silently + // fails here. + let long_enum = |padding: usize| { + Value::Array( + (0..1000) + .map(|index| { + Value::String(format!("private-enum-{index:04}-{}", "x".repeat(padding))) + }) + .collect(), + ) + }; + let mut at_limit = json!({ + "type": "object", + "properties": {"choice": {"type": "string", "enum": long_enum(57)}} + }); + sanitize_for_kimi_parameters(&mut at_limit) + .expect("exactly 75000 encoded enum chars sit at the budget and ship"); + let mut over = json!({ + "type": "object", + "properties": {"choice": {"type": "string", "enum": long_enum(58)}} + }); + assert_eq!( + sanitize_for_kimi_parameters(&mut over).unwrap_err(), + KimiParameterSchemaError::ResourceLimitExceeded + ); + } + + #[test] + fn kimi_parameters_total_enum_budget_accumulates_across_nodes() { + // The enum budget is document-wide (state.total_enum_values), while + // walle's MaxEnumItems counts per node — the constant block discloses + // the scope caveat. This pins the accumulation itself: two 500-value + // nodes ship at the 1000 total, and one extra value anywhere fails + // the whole tool. + let enum_values = |count: usize| { + Value::Array((0..count).map(|value| json!(value)).collect()) + }; + let schema = |left: usize, right: usize| { + json!({ + "type": "object", + "properties": { + "left": {"type": "integer", "enum": enum_values(left)}, + "right": {"type": "integer", "enum": enum_values(right)} + } + }) + }; + let mut at_total_budget = schema(500, 500); + sanitize_for_kimi_parameters(&mut at_total_budget) + .expect("1000 total enum values across two nodes ship"); + let mut over_total_budget = schema(500, 501); + assert_eq!( + sanitize_for_kimi_parameters(&mut over_total_budget).unwrap_err(), + KimiParameterSchemaError::ResourceLimitExceeded + ); + } + + #[test] + fn kimi_parameters_whole_document_byte_budget_rejects_oversized_schemas() { + // MFJS_MAX_SCHEMA_BYTES bounds the whole serialized document after + // the strip; it has no probe anywhere else, so pin both faces here. + let blob_schema = |description_len: usize| { + json!({ + "type": "object", + "properties": { + "blob": { + "type": "string", + "description": "y".repeat(description_len) + } + }, + "required": ["blob"] + }) + }; + let mut over = blob_schema(130_000); + assert_eq!( + sanitize_for_kimi_parameters(&mut over).unwrap_err(), + KimiParameterSchemaError::ResourceLimitExceeded + ); + let mut roomy = blob_schema(1_000); + sanitize_for_kimi_parameters(&mut roomy) + .expect("a small document sits far inside the byte budget"); + } + + #[test] + fn kimi_parameters_structural_depth_boundary_is_engine_local() { + // MFJS_MAX_STRUCTURAL_DEPTH=64 counts every node descent (items + // chains included) and is an engine-only recursion guard — walle has + // no counterpart budget. 63 wrapped item levels reach exactly 64 + // structural entries from the root and ship; one more level exceeds + // the guard. + let nested_items = |levels: usize| { + let mut node = json!({"type": "string"}); + for _ in 0..levels { + node = json!({"type": "array", "items": node}); + } + json!({ + "type": "object", + "properties": {"tree": node}, + "required": ["tree"] + }) + }; + let mut at_guard = nested_items(63); + sanitize_for_kimi_parameters(&mut at_guard) + .expect("64 structural levels reach the engine guard exactly and ship"); + let mut over = nested_items(64); + assert_eq!( + sanitize_for_kimi_parameters(&mut over).unwrap_err(), + KimiParameterSchemaError::ResourceLimitExceeded + ); + } + #[test] fn kimi_parameters_ships_default_on_any_node_without_type_check() { // The specification's `default` clause, verbatim: "default 字段为 From c4b9589924f7ca80216bc52551a3602754de3fdd Mon Sep 17 00:00:00 2001 From: asto Date: Fri, 6 Nov 2026 00:28:49 +0800 Subject: [PATCH 19/20] docs(client): correct the probe-era route-whitelist comments Three incompatible-tool fixtures carried the same stale comment from the superseded route-tier commit: "`pattern` itself is live-verified legal on the Kimi Code route since 2026-11-04 ... stays outside both route whitelists". The final state has one MFJS document whitelist for every Moonshot route, `pattern` fails closed everywhere (`probe_observed_keywords_fail_closed_on_every_route`), and the body's own probe declaration demotes live probes to observations that can never widen the wire. The comments now state that: `patternProperties` is simply one unlisted keyword used as the incompatible shape, and no route admits `pattern` or any other probe-observed keyword. Comment-only change; no behavior, no test churn. No-Issue: review follow-up for this PR's own series; the repository has issues disabled. Signed-off-by: asto --- crates/tui/src/client.rs | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 25941c7c2c..488f2a77b1 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -6600,9 +6600,11 @@ mod tests { k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("low"), streaming); let good = test_tool("compatible_lookup"); let mut bad = test_tool("mcp_pattern_tool"); - // `pattern` itself is live-verified legal on the Kimi Code route since - // 2026-11-04, so the incompatible-tool fixture uses `patternProperties`, - // which stays outside both route whitelists. + // One MFJS whitelist serves every Moonshot route: any unlisted + // keyword fails closed on all of them (see + // `probe_observed_keywords_fail_closed_on_every_route`), so + // `patternProperties` is simply one unlisted keyword used as the + // incompatible shape here — no route admits `pattern` either. bad.input_schema = json!({ "type": "object", "properties": {"query": {"type": "string"}}, @@ -6700,9 +6702,11 @@ mod tests { k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("low"), streaming); let good = test_tool("compatible_lookup"); let mut bad = test_tool("mcp_pattern_tool"); - // `pattern` itself is live-verified legal on the Kimi Code route since - // 2026-11-04, so the incompatible-tool fixture uses `patternProperties`, - // which stays outside both route whitelists. + // One MFJS whitelist serves every Moonshot route: any unlisted + // keyword fails closed on all of them (see + // `probe_observed_keywords_fail_closed_on_every_route`), so + // `patternProperties` is simply one unlisted keyword used as the + // incompatible shape here — no route admits `pattern` either. bad.input_schema = json!({ "type": "object", "properties": {"query": {"type": "string"}}, @@ -6770,9 +6774,11 @@ mod tests { let mut request = k3_request_fixture(crate::config::KIMI_CODE_K3_MODEL, Some("low"), true); let good = test_tool("compatible_lookup"); let mut bad = test_tool("mcp_pattern_tool"); - // `pattern` itself is live-verified legal on the Kimi Code route since - // 2026-11-04, so the incompatible-tool fixture uses `patternProperties`, - // which stays outside both route whitelists. + // One MFJS whitelist serves every Moonshot route: any unlisted + // keyword fails closed on all of them (see + // `probe_observed_keywords_fail_closed_on_every_route`), so + // `patternProperties` is simply one unlisted keyword used as the + // incompatible shape here — no route admits `pattern` either. bad.input_schema = json!({ "type": "object", "properties": {"query": {"type": "string"}}, From bed78918d2f2717c7a5cb03120edbb9f93720d35 Mon Sep 17 00:00:00 2001 From: asto Date: Fri, 6 Nov 2026 00:55:10 +0800 Subject: [PATCH 20/20] style: rustfmt the digit-led loop and budget probes Format-only; cargo fmt on the three files touched by the review follow-ups. Signed-off-by: asto --- crates/tui/src/client/chat.rs | 5 ++++- crates/tui/src/core/engine/turn_loop.rs | 17 +++++++---------- crates/tui/src/tools/schema_sanitize.rs | 5 ++--- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index de014b4643..00c58e1e4f 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -6941,7 +6941,10 @@ mod alias_thinking_detection_tests { // Other providers keep the registry spelling on the wire: the // documented leading-character ban is Moonshot-only. let messages = build_chat_messages_for_request_and_provider(&request, ApiProvider::Openai); - assert_eq!(messages[0]["tool_calls"][0]["function"]["name"], json!("3d_render")); + assert_eq!( + messages[0]["tool_calls"][0]["function"]["name"], + json!("3d_render") + ); } #[test] diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index ecf1c5a8a0..ef832ce727 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -7529,7 +7529,10 @@ fn resolve_tool_definition<'a>( && tool_name.as_bytes()[1].is_ascii_digit() { let unprefixed = tool_name[1..].to_string(); - if let Some(def) = tool_catalog.iter().find(|def| def.name.as_str() == unprefixed) { + if let Some(def) = tool_catalog + .iter() + .find(|def| def.name.as_str() == unprefixed) + { tool_def = Some(def); *tool_name = unprefixed; } else if tool_registry.is_some_and(|registry| registry.get(&unprefixed).is_some()) { @@ -8614,9 +8617,7 @@ mod tests { let tmp = tempfile::tempdir().expect("tempdir"); let context = crate::tools::spec::ToolContext::new(tmp.path().to_path_buf()); let mut registry = crate::tools::ToolRegistry::new(context); - registry.register(std::sync::Arc::new(NamedProbeTool { - name: "3d_render", - })); + registry.register(std::sync::Arc::new(NamedProbeTool { name: "3d_render" })); let catalog = registry.to_api_tools(); let mut name = "_3d_render".to_string(); @@ -8640,12 +8641,8 @@ mod tests { let tmp = tempfile::tempdir().expect("tempdir"); let context = crate::tools::spec::ToolContext::new(tmp.path().to_path_buf()); let mut registry = crate::tools::ToolRegistry::new(context); - registry.register(std::sync::Arc::new(NamedProbeTool { - name: "3d_render", - })); - registry.register(std::sync::Arc::new(NamedProbeTool { - name: "_3d_render", - })); + registry.register(std::sync::Arc::new(NamedProbeTool { name: "3d_render" })); + registry.register(std::sync::Arc::new(NamedProbeTool { name: "_3d_render" })); let catalog = registry.to_api_tools(); let mut name = "_3d_render".to_string(); diff --git a/crates/tui/src/tools/schema_sanitize.rs b/crates/tui/src/tools/schema_sanitize.rs index d2e057a786..13316ff649 100644 --- a/crates/tui/src/tools/schema_sanitize.rs +++ b/crates/tui/src/tools/schema_sanitize.rs @@ -3738,9 +3738,8 @@ mod kimi_tests { // the scope caveat. This pins the accumulation itself: two 500-value // nodes ship at the 1000 total, and one extra value anywhere fails // the whole tool. - let enum_values = |count: usize| { - Value::Array((0..count).map(|value| json!(value)).collect()) - }; + let enum_values = + |count: usize| Value::Array((0..count).map(|value| json!(value)).collect()); let schema = |left: usize, right: usize| { json!({ "type": "object",