diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 5051141363..488f2a77b1 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); @@ -6589,11 +6600,15 @@ 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"); + // 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", "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")); @@ -6687,11 +6702,15 @@ 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"); + // 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", "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!({ @@ -6755,11 +6774,15 @@ 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"); + // 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", "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 83d3cbee1c..00c58e1e4f 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -17,8 +17,7 @@ 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, + is_exact_zai_tiered_effort_route, minimax_m3_route_uses_max_completion_tokens, wire_model_for_provider_route, }; @@ -940,21 +939,74 @@ 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. -/// 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) - { +/// 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. +#[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. 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 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; } if let Some(object) = body.as_object_mut() { @@ -1053,6 +1105,11 @@ 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. /// +/// 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) -> Vec { let mut dropped = Vec::new(); @@ -1102,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 @@ -1178,11 +1294,14 @@ 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 (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) { + 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 @@ -1222,15 +1341,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; @@ -1244,7 +1389,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 @@ -1847,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 } @@ -6506,7 +6654,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 +6665,418 @@ 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 + // 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 + // (`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::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::Openrouter, 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, - crate::config::DEFAULT_KIMI_CODE_BASE_URL, - "k3-256k-preview", + "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 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 + ); + } + } + + #[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 + // 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_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(), + 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]); + + for base_url in [ + "https://api.kimi.ai/coding/v1", + crate::config::DEFAULT_KIMI_CODE_BASE_URL, + ] { + 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 + ); } } @@ -8339,6 +8875,225 @@ mod image_block_wire_tests { "a non-empty description keeps the appended-note form" ); } + + /// 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_flattens_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 flattenable union must not be dropped: {:?}", + body.omitted_tool_names + ); + let parameters = &body.body["tools"][0]["function"]["parameters"]; + assert_eq!( + parameters["type"], "object", + "the wire root must be a plain object schema: {parameters}" + ); + assert!( + 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("required").is_none(), + "no required set survives a union of disagreeing branches: {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, + } + } + + /// 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 probe_observed_keywords_never_reach_the_wire() { + 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); + + for base_url in [ + crate::config::DEFAULT_KIMI_CODE_BASE_URL, + crate::config::DEFAULT_MOONSHOT_BASE_URL, + ] { + let body = build_chat_wire_body(&request, ApiProvider::Moonshot, base_url, false) + .expect("Moonshot wire body"); + + 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 + ); + } + } + + /// 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 advisory_keywords_are_stripped_from_the_wire() { + let schema = serde_json::json!({ + "title": "Annotated tool", + "type": "object", + "properties": { + "label": {"type": "string", "title": "Label", "minLength": 1, "maxLength": 40}, + "price": {"type": "number", "minimum": 0, "maximum": 100} + }, + "required": ["label"] + }); + let request = single_tool_request("annotated_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(), + "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}" + ); + } } #[cfg(test)] 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, diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index c01e675186..ef832ce727 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -7515,6 +7515,31 @@ 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 +8567,90 @@ 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"); diff --git a/crates/tui/src/tools/schema_sanitize.rs b/crates/tui/src/tools/schema_sanitize.rs index bdc0bda33b..13316ff649 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; @@ -380,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(" | ") @@ -397,6 +436,13 @@ 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). 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(|| { "This provider cannot express conditional requirements from the original schema. \ @@ -1095,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. @@ -1206,10 +1280,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, - #[error("Moonshot function parameters contain an invalid MFJS range")] - InvalidRange, } /// Normalize a complete Kimi / Moonshot `function.parameters` object. @@ -1217,10 +1287,19 @@ pub enum KimiParameterSchemaError { /// Function parameters have an additional MFJS constraint: the root must end /// 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 +/// `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. /// +/// 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. /// @@ -1253,18 +1332,27 @@ 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 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 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 // 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. + // keeps responsibility for validating its own arguments. Nested allOf + // fails closed. normalize_kimi_compatibility(parameters, true)?; // MFJS requires `type` to live inside each anyOf branch, never alongside @@ -1272,6 +1360,15 @@ fn sanitize_kimi_parameters_candidate( // 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 @@ -1295,6 +1392,139 @@ 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 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 尚未提供官方支持", +/// , +/// 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. 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.iter().all(|kind| kind == &kinds[0])); + if !homogeneous { + obj.shift_remove("enum"); + } + } +} + /// Drop every empty `items` schema node (`"items": {}`) from a Kimi /// parameters document. /// @@ -1308,9 +1538,11 @@ fn sanitize_kimi_parameters_candidate( /// `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. /// @@ -1413,6 +1645,10 @@ fn normalize_kimi_compatibility( if !is_root { 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") { @@ -1424,18 +1660,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 @@ -1506,8 +1753,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. @@ -1515,6 +1767,15 @@ 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. +/// +/// 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() @@ -1551,14 +1812,33 @@ 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. 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; +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; +// 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> { @@ -1598,8 +1878,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", @@ -1609,15 +1894,8 @@ impl<'a> MfjsValidationState<'a> { "type", "enum", "required", - "maxLength", - "minLength", - "maximum", - "minimum", - "maxItems", - "minItems", - "title", - "description", "default", + "description", ]; if obj .keys() @@ -1626,13 +1904,9 @@ impl<'a> MfjsValidationState<'a> { 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")) + if obj + .get("description") + .is_some_and(|value| !value.is_string()) { return Err(KimiParameterSchemaError::InvalidKeywordValue); } @@ -1660,8 +1934,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); @@ -1675,8 +1948,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); @@ -1702,9 +1974,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 @@ -1773,7 +2042,6 @@ impl<'a> MfjsValidationState<'a> { self.validate_schema(items, false, false, property_depth, structural_depth + 1)?; } - validate_mfjs_bounds(obj, schema_type)?; Ok(()) } } @@ -1786,20 +2054,30 @@ fn is_mfjs_concrete_type(schema_type: &str) -> bool { } fn mfjs_keyword_allowed_for_type(keyword: &str, schema_type: &str, is_root: bool) -> bool { - if is_root && matches!(keyword, "$defs" | "$id") { + // 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. + // + // `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" | "title" | "description") { + if matches!(keyword, "type" | "description" | "default") { return true; } match schema_type { "object" => matches!(keyword, "properties" | "required" | "additionalProperties"), - "array" => matches!(keyword, "items" | "minItems" | "maxItems"), - "string" => matches!(keyword, "enum" | "default" | "minLength" | "maxLength"), - "number" | "integer" => { - matches!(keyword, "enum" | "default" | "minimum" | "maximum") - } - "boolean" | "null" => keyword == "default", + "array" => keyword == "items", + "string" | "number" | "integer" => keyword == "enum", + "boolean" | "null" => false, _ => false, } } @@ -1867,83 +2145,12 @@ 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) .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")?; - - 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 - .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); - } - } - 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); @@ -2023,16 +2230,24 @@ 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. - if obj.get("minItems").and_then(Value::as_u64).unwrap_or(0) == 0 { + // `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); } - 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. + 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), @@ -2229,7 +2444,13 @@ mod kimi_tests { } // Function parameters must end as a plain object root. Composition stays - // available only in valid nested anyOf positions. + // 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() { @@ -2262,15 +2483,72 @@ mod kimi_tests { fn kimi_parameters_add_type_to_oneof_root() { 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"); + 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 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": [ + { + "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 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": [ + { + "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] @@ -2408,10 +2686,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(); @@ -2419,10 +2699,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!({ @@ -2670,57 +2995,32 @@ 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 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"] }); - 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:?}" - ); - - // `minItems: 0` terminates trivially at the same checked position, - // and its only MFJS-legal shape repairs to the equivalent - // unconstrained form. - let mut schema = json!({ - "type": "object", - "properties": {"a": {"type": "array", "items": {}, "minItems": 0}}, - "required": ["a"] - }); - sanitize_for_kimi_parameters(&mut schema).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!({ - "type": "object", - "properties": { - "s": {"type": "array", "items": {}, "minItems": 1} - } - }); sanitize_for_kimi_parameters(&mut schema) - .expect("a non-required position ships the equivalent repaired schema"); + .expect("a required array without items terminates after the repairs"); assert_eq!( - schema["properties"]["s"], - json!({"type": "array", "minItems": 1}) + schema["properties"]["a"], + json!({"type": "array"}), + "empty items and unlisted minItems both repair to the absent form" ); - // 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. + // 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!({ "type": "object", "properties": { @@ -2733,38 +3033,11 @@ mod kimi_tests { }, "required": ["a"] }); - let error = sanitize_for_kimi_parameters(&mut schema) - .expect_err("minItems >= 1 cannot terminate in a checked anyOf branch"); - assert!( - matches!(error, KimiParameterSchemaError::InvalidSchemaNode), - "the anyOf branch's variant is InvalidSchemaNode: {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!({ - "type": "object", - "properties": { - "a": { - "anyOf": [ - {"type": "string"}, - {"type": "array", "items": {}, "minItems": 1} - ] - } - }, - "required": ["a"] - }); sanitize_for_kimi_parameters(&mut schema) - .expect("a terminating branch ahead of the offending one ships the tool"); + .expect("an anyOf branch array without items terminates"); assert_eq!( - schema["properties"]["a"]["anyOf"][1], - json!({"type": "array", "minItems": 1}), - "the shipped branch is the repaired, instance-set-equivalent form" + schema["properties"]["a"]["anyOf"][0], + json!({"type": "array"}) ); let mut schema = json!({ @@ -2773,43 +3046,385 @@ mod kimi_tests { "properties": {"x": {"$ref": "#/$defs/a"}}, "required": ["x"] }); - 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:?}" - ); - } + sanitize_for_kimi_parameters(&mut schema) + .expect("a $ref target array without items terminates"); + assert_eq!(schema["$defs"]["a"], json!({"type": "array"})); - #[test] - fn kimi_parameters_fail_closed_shapes_stay_dropped() { - // The body's fail-closed enumeration, each pinned so a validator - // change cannot silence the drop silently: empty `anyOf` branches - // (including one the nested repair itself empties), empty `$defs` - // entries, boolean and tuple `items`. + // 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": { - "choice": {"anyOf": [{"type": "string"}, {}]} - } + "properties": {"a": {"type": "array", "minItems": 1, "items": {"type": "string"}}}, + "required": ["a"] }); - let error = sanitize_for_kimi_parameters(&mut schema) - .expect_err("an empty anyOf branch fails closed"); - assert!( - matches!(error, KimiParameterSchemaError::MissingType), - "empty branch drops with MissingType: {error:?}" + sanitize_for_kimi_parameters(&mut schema) + .expect("the carried items schema survives with minItems stripped"); + assert_eq!( + schema["properties"]["a"], + json!({"type": "array", "items": {"type": "string"}}) ); - let mut schema = json!({ + // 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": { - "choice": {"anyOf": [{"type": "string"}, {"items": {"items": {}}}]} - } - }); - let error = sanitize_for_kimi_parameters(&mut schema) - .expect_err("a branch the nested repair empties to {} fails closed"); - assert!( - matches!(error, KimiParameterSchemaError::MissingType), + "a": { + "type": "array", + "minItems": 1, + "prefixItems": [{"type": "string"}] + } + }, + "required": ["a"] + }); + validate_mfjs_parameters(&schema).expect_err("prefixItems is outside the document set"); + + let schema = json!({ + "type": "object", + "properties": { + "a": { + "type": "array", + "prefixItems": [ + {"type": "array", "items": {"$ref": "#"}} + ] + } + }, + "required": ["a"] + }); + validate_mfjs_parameters(&schema) + .expect_err("a non-terminating tuple prefix still fails closed"); + } + + 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", + "properties": { + "label": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "format": "uri" + }, + "price": { + "type": "number", + "exclusiveMinimum": 0 + }, + "rank": {"type": "integer", "exclusiveMaximum": 10}, + "row": { + "type": "array", + "prefixItems": [{"type": "string"}, {"type": "integer"}] + }, + "target": { + "type": "object", + "allOf": [ + { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"] + }, + { + "type": "object", + "properties": {"mode": {"type": "string"}} + } + ] + } + }, + "required": ["label"] + }) + } + + #[test] + 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. 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 every route's document whitelist"); + assert!( + matches!( + error, + KimiParameterSchemaError::UnsupportedKeyword + | KimiParameterSchemaError::UnsupportedNestedAllOf + ), + "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": { + "target": { + "type": "object", + "allOf": [ + { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"] + }, + { + "type": "object", + "properties": {"mode": {"type": "string"}} + } + ] + } + } + }); + // 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) + .expect_err("nested allOf keeps failing closed"); + assert!( + matches!(error, KimiParameterSchemaError::UnsupportedNestedAllOf), + "nested allOf keeps the fail-closed drop: {error:?}" + ); + } + + #[test] + 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": { + "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"] + }); + + 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": { + "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"]]} + } + }); + + 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": { + "role": {"type": "string", "enum": ["fast", "full"]}, + "level": {"type": "integer", "enum": [1, 2]}, + "ratio": {"type": "number", "enum": [0.5, 1.5]} + } + }); + 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] + 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 + // change cannot silence the drop silently: empty `anyOf` branches + // (including one the nested repair itself empties), empty `$defs` + // entries, boolean and tuple `items`. + let mut schema = json!({ + "type": "object", + "properties": { + "choice": {"anyOf": [{"type": "string"}, {}]} + } + }); + let error = sanitize_for_kimi_parameters(&mut schema) + .expect_err("an empty anyOf branch fails closed"); + assert!( + matches!(error, KimiParameterSchemaError::MissingType), + "empty branch drops with MissingType: {error:?}" + ); + + let mut schema = json!({ + "type": "object", + "properties": { + "choice": {"anyOf": [{"type": "string"}, {"items": {"items": {}}}]} + } + }); + let error = sanitize_for_kimi_parameters(&mut schema) + .expect_err("a branch the nested repair empties to {} fails closed"); + assert!( + matches!(error, KimiParameterSchemaError::MissingType), "nested-emptied branch drops with MissingType: {error:?}" ); @@ -2955,40 +3570,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 @@ -2997,13 +3629,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 @@ -3012,30 +3649,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(), @@ -3044,7 +3700,128 @@ mod kimi_tests { } #[test] - fn kimi_parameters_validates_default_type_and_placement_without_leaks() { + 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 字段为 + // 目标输出提供一个默认值……这儿不做类型检查,需要调用者自行保证其值 + // 类型内容和所在位置期望的内容相匹配" (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": { @@ -3052,92 +3829,111 @@ 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"])); + + // 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); + } - for (mut invalid, expected) in [ + #[test] + 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": { - "private-default-3301": { - "type": "integer", - "default": "private-default-value-3302" - } + "value": {"type": "string", "minLength": 5, "maxLength": 4} } }), - KimiParameterSchemaError::InvalidDefault, + vec!["minLength", "maxLength"], ), ( json!({ "type": "object", "properties": { - "private-untyped-default-3303": {"default": 1} + "value": {"type": "array", "minItems": 3, "maxItems": 2} } }), - KimiParameterSchemaError::MissingType, + vec!["minItems", "maxItems"], ), ( json!({ "type": "object", "properties": { - "private-object-default-3304": { - "type": "object", - "default": {} - } + "value": {"type": "number", "minimum": 10, "maximum": 9} } }), - KimiParameterSchemaError::InvalidKeywordValue, + 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"], ), ] { - 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); - } - } - - #[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} - } - }), - ] { - assert_eq!( - sanitize_for_kimi_parameters(&mut schema).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"); } } @@ -3310,4 +4106,222 @@ 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, 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_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( + 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" + ); + // 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); + + // 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"); + 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 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(), + shipped_branches.len(), + "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}" + ); + } + + // 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/tests/fixtures/pptx_make_pptx_schema.json b/crates/tui/tests/fixtures/pptx_make_pptx_schema.json new file mode 100644 index 0000000000..001af9ab81 --- /dev/null +++ b/crates/tui/tests/fixtures/pptx_make_pptx_schema.json @@ -0,0 +1,410 @@ +{ + "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` 选版式,并**按版式填该分支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": [ + { + "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 条);title 可选,缺省「目录」", + "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", + "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": "结尾页:layout 即完整,标题缺省「谢谢」;subtitle 可选", + "properties": { + "layout": { + "type": "string", + "enum": [ + "end" + ], + "description": "版式:结尾页" + }, + "title": { + "type": "string", + "description": "页标题" + }, + "subtitle": { + "type": "string", + "description": "副标题" + } + }, + "required": [ + "layout" + ] + } + ] + } + }, + "filename": { + "type": "string", + "description": "文件名(不含扩展名),缺省 演示文稿" + } + }, + "required": [ + "slides" + ] + } + } + ] +}