//! Kimi (Moonshot) chat/completions request adaptations. //! //! The Kimi endpoints are OpenAI-compatible but deviate in a handful of //! places (PRD F3 Q1). Every request-side deviation is absorbed HERE, in a //! single adaptation point applied to the serialized chat/completions body //! just before it is sent — never as scattered special-cases at call sites. //! Each adaptation cites the kimi-cli source it was derived from //! (kimi-cli == the authoritative official client; paths are relative to //! that repository). //! //! Response-side deviations live with the wire types themselves //! (`kigi_sampling_types::Usage::cached_tokens`, //! `ChatChunkChoice::usage`) and the L2 stream transform //! (`stream::chat_completions` synthesizes missing tool-call ids). //! //! The `ApiBackend::ChatCompletions` backend is the Kimi dialect: both //! product channels (subscription OAuth and Moonshot API keys) ride it. //! Custom providers that need vanilla OpenAI semantics for reasoning use //! the `Responses` backend, which stays available in model configuration. use serde_json::Value; /// Adapt a fully-serialized chat/completions request body to the Kimi /// dialect, in place. Applied by [`crate::SamplingClient`] to both the /// streaming and non-streaming chat/completions paths. pub(crate) fn adapt_chat_completions_body(body: &mut Value) { adapt_thinking(body); adapt_messages(body); adapt_tool_schemas(body); } /// Dialect-dispatched body adaptation. Kimi keeps the full historical /// pipeline (thinking + message hygiene + schema normalization — all built /// for the Kimi wire's strictness); DeepSeek differs ONLY in how thinking /// rides the body; Passthrough providers take OpenAI-style bodies verbatim /// (their `reasoning_effort` scalar is already the wire form). pub(crate) fn adapt_chat_completions_body_for( compat: kigi_sampling_types::ChatCompat, body: &mut Value, ) { match compat { kigi_sampling_types::ChatCompat::Kimi => adapt_chat_completions_body(body), kigi_sampling_types::ChatCompat::DeepSeek => { adapt_thinking_deepseek(body); strip_kigi_private_message_fields(body); } kigi_sampling_types::ChatCompat::Passthrough => { strip_kigi_private_message_fields(body); } kigi_sampling_types::ChatCompat::StrictOpenAi => { strip_kigi_private_message_fields(body); strip_stream_options(body); } } } /// Mistral's strict Pydantic validator 422-rejects `stream_options` /// (`extra_forbidden` on `stream_options.include_usage`; its request model /// has no such field). kigi injects `stream_options.include_usage` on every /// streaming request for the other providers, so strip the whole object for /// Mistral. Streaming usage falls back to token estimation (as for any /// provider that omits streaming usage). fn strip_stream_options(body: &mut Value) { if let Some(obj) = body.as_object_mut() { obj.remove("stream_options"); } } /// Remove kigi-internal history artifacts from input messages before they /// reach a non-Kimi wire. `reasoning_content` is Kimi's replayed-thinking /// field (Kimi consumes it; DeepSeek documents it as prefix-mode-only and /// historically 400s on it; other providers don't know it) and `model_id` /// is kigi's private per-message provenance. Kimi's own pipeline handles /// these in `adapt_messages`. fn strip_kigi_private_message_fields(body: &mut Value) { let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) else { return; }; for message in messages { if let Some(obj) = message.as_object_mut() { obj.remove("reasoning_content"); obj.remove("model_id"); } } } /// DeepSeek spells the thinking control `thinking:{type, reasoning_effort}` /// (api-docs.deepseek.com create-chat-completion; the server maps /// low/medium→high and xhigh→max itself, so the canonical level passes /// through verbatim). `none` disables thinking; absent leaves the server /// default (enabled). fn adapt_thinking_deepseek(body: &mut Value) { let Some(obj) = body.as_object_mut() else { return; }; let Some(effort) = obj.remove("reasoning_effort") else { return; }; let Some(level) = effort.as_str().map(str::to_owned) else { return; }; if level == "none" { obj.insert( "thinking".to_string(), serde_json::json!({ "type": "disabled" }), ); } else { obj.insert( "thinking".to_string(), serde_json::json!({ "type": "enabled", "reasoning_effort": level }), ); } } /// Map the OpenAI-style `reasoning_effort` knob onto Kimi's `thinking` /// request field and drop `reasoning_effort` from the wire. /// /// kimi-cli 1.49.0 controls thinking through the request body's /// `thinking: {"type": "enabled" | "disabled"}` field /// (packages/kosong/src/kosong/chat_provider/kimi.py:214-223 `with_thinking`: /// `"enabled" if effort != "off" else "disabled"`; wired by /// src/kimi_cli/llm.py:475-481). When no effort is configured, nothing is /// sent and the server default applies (llm.py:482 "leave as-is"). /// /// Models with selectable levels (the `/models` `think_efforts` block, e.g. /// K3's low/high/max) additionally take the level as `thinking.effort` — /// verified against the live api.kimi.com: `{"type": "enabled", "effort": /// "low"}` is accepted, values outside `valid_efforts` are a 400. The /// catalog gates efforts to that per-model list, so this layer only renames /// the one canonical-vs-wire divergence (`xhigh` → `max`) and passes the /// level through verbatim — inventing or clamping a level here would hide a /// real contract violation. fn adapt_thinking(body: &mut Value) { let Some(obj) = body.as_object_mut() else { return; }; let Some(effort) = obj.remove("reasoning_effort") else { return; }; let effort = effort.as_str().map(str::to_owned); let enabled = effort.as_deref() != Some("none"); let mut thinking = serde_json::Map::new(); thinking.insert( "type".to_owned(), Value::String(if enabled { "enabled" } else { "disabled" }.to_owned()), ); if enabled && let Some(level) = effort { let wire_level = if level == "xhigh" { "max".to_owned() } else { level }; thinking.insert("effort".to_owned(), Value::String(wire_level)); } obj.insert("thinking".to_owned(), Value::Object(thinking)); } /// Message-level adaptations: /// /// * Drop `model_id` — a kigi extension recorded on assistant turns; /// kimi-cli's message serializer sends no such field /// (packages/kosong/src/kosong/chat_provider/kimi.py:326-353). /// * Drop `content` from assistant tool-call messages whose visible content /// is effectively empty. The Kimi-for-Coding compat layer rejects an /// empty text content part with 400 "text content is empty"; omitting /// `content` entirely is always accepted /// (packages/kosong/src/kosong/chat_provider/kimi.py:339-350, with the /// "effectively empty" predicate at kimi.py:356-362). fn adapt_messages(body: &mut Value) { let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else { return; }; for message in messages { let Some(obj) = message.as_object_mut() else { continue; }; obj.remove("model_id"); let is_assistant = obj.get("role").and_then(Value::as_str) == Some("assistant"); let has_tool_calls = obj .get("tool_calls") .and_then(Value::as_array) .is_some_and(|calls| !calls.is_empty()); if is_assistant && has_tool_calls && obj.get("content").is_some_and(is_effectively_empty_content) { obj.remove("content"); } } } /// Port of kimi-cli `_is_effectively_empty_content_parts` /// (packages/kosong/src/kosong/chat_provider/kimi.py:356-362): a bare /// whitespace-only string, or a block list whose entries are all /// whitespace-only text blocks. Any non-text block (e.g. an image) makes /// the content non-empty. fn is_effectively_empty_content(content: &Value) -> bool { match content { Value::String(s) => s.trim().is_empty(), Value::Array(blocks) => blocks.iter().all(|block| { block.get("type").and_then(Value::as_str) == Some("text") && block .get("text") .and_then(Value::as_str) .is_some_and(|t| t.trim().is_empty()) }), Value::Null => true, _ => false, } } /// Moonshot's schema validator rejects tool parameter schemas whose /// property schemas omit `type` (e.g. enum-only properties exposed by some /// MCP servers): HTTP 400 "At path 'properties.X': type is not defined". /// Fill in an inferred `type` locally so such tools keep working. Port of /// kimi-cli `ensure_property_types` /// (packages/kosong/src/kosong/utils/jsonschema.py:88-142, applied per tool /// at packages/kosong/src/kosong/chat_provider/kimi.py:378-388). fn adapt_tool_schemas(body: &mut Value) { let Some(tools) = body.get_mut("tools").and_then(Value::as_array_mut) else { return; }; for tool in tools { if let Some(parameters) = tool.pointer_mut("/function/parameters") { recurse_schema(parameters); } } } /// JSON Schema keywords that describe a property's shape without a `type` /// keyword; nodes carrying one are left alone /// (kosong/utils/jsonschema.py:15-24 `_COMBINATOR_KEYS`). const COMBINATOR_KEYS: [&str; 8] = [ "anyOf", "oneOf", "allOf", "not", "if", "then", "else", "$ref", ]; /// Walk property-schema positions under `node` (`properties`, `items`, /// `additionalProperties`, `anyOf`/`oneOf`/`allOf`); `node` itself is a /// container and is not normalized (kosong/utils/jsonschema.py:114-142). fn recurse_schema(node: &mut Value) { let Some(obj) = node.as_object_mut() else { return; }; if let Some(props) = obj.get_mut("properties").and_then(Value::as_object_mut) { for value in props.values_mut() { normalize_property(value); } } match obj.get_mut("items") { Some(items @ Value::Object(_)) => normalize_property(items), Some(Value::Array(items)) => { for value in items { normalize_property(value); } } _ => {} } if let Some(additional @ Value::Object(_)) = obj.get_mut("additionalProperties") { normalize_property(additional); } for key in ["anyOf", "oneOf", "allOf"] { if let Some(branches) = obj.get_mut(key).and_then(Value::as_array_mut) { for value in branches { normalize_property(value); } } } } /// Ensure a property schema declares `type`, then recurse into it /// (kosong/utils/jsonschema.py:145-162 `_normalize_property`). fn normalize_property(node: &mut Value) { let Some(obj) = node.as_object_mut() else { return; }; if !obj.contains_key("type") && !COMBINATOR_KEYS.iter().any(|k| obj.contains_key(*k)) { let inferred = if let Some(Value::Array(values)) = obj.get("enum") { if values.is_empty() { infer_type_from_structure(obj) } else { infer_type_from_values(values) } } else if let Some(constant) = obj.get("const") { infer_type_from_values(std::slice::from_ref(constant)) } else { infer_type_from_structure(obj) }; obj.insert("type".to_owned(), Value::String(inferred.to_owned())); } recurse_schema(node); } /// Infer `type` from structural keywords when no enum/const is present; /// defaults to `"string"` only with no structural hints at all /// (kosong/utils/jsonschema.py:165-215 `_infer_type_from_structure`). fn infer_type_from_structure(obj: &serde_json::Map) -> &'static str { const OBJECT_KEYWORDS: [&str; 7] = [ "properties", "additionalProperties", "patternProperties", "propertyNames", "required", "minProperties", "maxProperties", ]; const ARRAY_KEYWORDS: [&str; 6] = [ "items", "prefixItems", "minItems", "maxItems", "uniqueItems", "contains", ]; const STRING_KEYWORDS: [&str; 4] = ["minLength", "maxLength", "pattern", "format"]; const NUMERIC_KEYWORDS: [&str; 5] = [ "minimum", "maximum", "multipleOf", "exclusiveMinimum", "exclusiveMaximum", ]; if OBJECT_KEYWORDS.iter().any(|k| obj.contains_key(*k)) { "object" } else if ARRAY_KEYWORDS.iter().any(|k| obj.contains_key(*k)) { "array" } else if STRING_KEYWORDS.iter().any(|k| obj.contains_key(*k)) { "string" } else if NUMERIC_KEYWORDS.iter().any(|k| obj.contains_key(*k)) { "number" } else { "string" } } /// Infer a `type` from concrete enum/const values: single JSON type wins, /// `{integer, number}` collapses to `"number"`, any other mix falls back to /// `"string"` (kosong/utils/jsonschema.py:218-247 `_infer_type_from_values`). fn infer_type_from_values(values: &[Value]) -> &'static str { let mut inferred = std::collections::BTreeSet::new(); for value in values { let ty = match value { Value::Bool(_) => "boolean", Value::Number(n) if n.is_i64() || n.is_u64() => "integer", Value::Number(_) => "number", Value::String(_) => "string", Value::Null => "null", Value::Object(_) => "object", Value::Array(_) => "array", }; inferred.insert(ty); } if inferred.len() == 1 { return inferred.pop_first().expect("non-empty set"); } if inferred == std::collections::BTreeSet::from(["integer", "number"]) { return "number"; } "string" } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn deepseek_dialect_spells_thinking_reasoning_effort() { use kigi_sampling_types::ChatCompat; // Official docs: thinking:{type, reasoning_effort}; server maps // low/medium→high, xhigh→max itself — levels pass through verbatim. let mut body = json!({ "model": "deepseek-v4-pro", "reasoning_effort": "high" }); adapt_chat_completions_body_for(ChatCompat::DeepSeek, &mut body); assert_eq!(body.get("reasoning_effort"), None); assert_eq!( body["thinking"], json!({ "type": "enabled", "reasoning_effort": "high" }) ); let mut body = json!({ "model": "deepseek-v4-flash", "reasoning_effort": "max" }); adapt_chat_completions_body_for(ChatCompat::DeepSeek, &mut body); assert_eq!( body["thinking"], json!({ "type": "enabled", "reasoning_effort": "max" }) ); // none disables; absent leaves the server default (no thinking key). let mut body = json!({ "reasoning_effort": "none" }); adapt_chat_completions_body_for(ChatCompat::DeepSeek, &mut body); assert_eq!(body["thinking"], json!({ "type": "disabled" })); let mut body = json!({ "model": "deepseek-chat" }); adapt_chat_completions_body_for(ChatCompat::DeepSeek, &mut body); assert_eq!(body.get("thinking"), None); // DeepSeek does NOT get kimi's message/tool-schema rewrites (empty // assistant tool-call content survives — DeepSeek's documented // function-calling round-trip uses that shape), but kigi-private // fields are stripped: replayed reasoning_content is prefix-mode-only // on the DeepSeek wire (historically a 400 in input messages). let mut body = json!({ "reasoning_effort": "high", "messages": [ { "role": "assistant", "content": "", "tool_calls": [{}], "reasoning_content": "replayed thinking", "model_id": "kigi/x" } ] }); adapt_chat_completions_body_for(ChatCompat::DeepSeek, &mut body); assert_eq!(body["messages"][0]["content"], json!("")); assert_eq!(body["messages"][0].get("reasoning_content"), None); assert_eq!(body["messages"][0].get("model_id"), None); } #[test] fn strict_openai_dialect_strips_stream_options_and_private_fields() { use kigi_sampling_types::ChatCompat; // Mistral 422s on stream_options (extra_forbidden) and doesn't know // kigi's private message fields; OpenAI-style reasoning_effort stays. let mut body = json!({ "model": "mistral-medium-latest", "reasoning_effort": "high", "stream": true, "stream_options": { "include_usage": true }, "messages": [ { "role": "assistant", "content": "hi", "reasoning_content": "internal", "model_id": "kigi/x" } ] }); adapt_chat_completions_body_for(ChatCompat::StrictOpenAi, &mut body); assert_eq!( body.get("stream_options"), None, "stream_options must be stripped" ); assert_eq!(body["stream"], json!(true), "stream flag stays"); assert_eq!( body["reasoning_effort"], json!("high"), "OpenAI-style effort passes through (Mistral accepts it natively)" ); assert_eq!(body["messages"][0].get("reasoning_content"), None); assert_eq!(body["messages"][0].get("model_id"), None); assert_eq!(body["messages"][0]["content"], json!("hi")); } #[test] fn passthrough_dialect_leaves_openai_body_verbatim() { use kigi_sampling_types::ChatCompat; // Verbatim EXCEPT kigi-private history artifacts, which no non-Kimi // wire understands. let mut body = json!({ "model": "gpt-oss", "reasoning_effort": "high", "messages": [ { "role": "user", "content": "hi" }, { "role": "assistant", "content": "yo", "reasoning_content": "internal", "model_id": "kigi/x" } ] }); adapt_chat_completions_body_for(ChatCompat::Passthrough, &mut body); assert_eq!( body, json!({ "model": "gpt-oss", "reasoning_effort": "high", "messages": [ { "role": "user", "content": "hi" }, { "role": "assistant", "content": "yo" } ] }), "reasoning_effort stays OpenAI-style; private fields are stripped" ); } #[test] fn kimi_dialect_dispatch_matches_legacy_pipeline() { use kigi_sampling_types::ChatCompat; let mut via_dispatch = json!({ "model": "k3", "reasoning_effort": "max" }); adapt_chat_completions_body_for(ChatCompat::Kimi, &mut via_dispatch); let mut via_legacy = json!({ "model": "k3", "reasoning_effort": "max" }); adapt_chat_completions_body(&mut via_legacy); assert_eq!(via_dispatch, via_legacy, "Kimi dispatch = legacy pipeline"); } #[test] fn reasoning_effort_maps_to_kimi_thinking_field() { // Level rides along as thinking.effort (live wire: 200 with // {"type": "enabled", "effort": "low"}). let mut body = json!({ "model": "kimi-for-coding", "reasoning_effort": "high" }); adapt_chat_completions_body(&mut body); assert_eq!(body.get("reasoning_effort"), None); assert_eq!( body["thinking"], json!({ "type": "enabled", "effort": "high" }) ); // Legacy canonical `xhigh` (pre-Max configs/sessions) is spelled // `max` on the Kimi wire (the K3 valid_efforts vocabulary is // low/high/max — there is no `xhigh` there). let mut body = json!({ "model": "k3", "reasoning_effort": "xhigh" }); adapt_chat_completions_body(&mut body); assert_eq!( body["thinking"], json!({ "type": "enabled", "effort": "max" }) ); // Canonical `max` (what the K3 menu token parses to since the // ReasoningEffort::Max split) passes through unchanged. let mut body = json!({ "model": "k3", "reasoning_effort": "max" }); adapt_chat_completions_body(&mut body); assert_eq!( body["thinking"], json!({ "type": "enabled", "effort": "max" }) ); // kimi.py:218: "off" (our ReasoningEffort::None) → disabled, and no // effort key (a disabled+effort combination would be contradictory). let mut body = json!({ "reasoning_effort": "none" }); adapt_chat_completions_body(&mut body); assert_eq!(body["thinking"], json!({ "type": "disabled" })); // llm.py:482: unset → leave as-is (no `thinking` at all). let mut body = json!({ "model": "kimi-for-coding" }); adapt_chat_completions_body(&mut body); assert_eq!(body.get("thinking"), None); } #[test] fn assistant_tool_call_with_empty_content_drops_content() { let mut body = json!({ "messages": [ { "role": "user", "content": "hi" }, { "role": "assistant", "content": "", "model_id": "kimi-for-coding", "tool_calls": [{ "id": "c1", "type": "function", "function": { "name": "f", "arguments": "{}" } }] }, ] }); adapt_chat_completions_body(&mut body); let assistant = &body["messages"][1]; assert_eq!(assistant.get("content"), None, "empty content dropped"); assert_eq!(assistant.get("model_id"), None, "kigi extension dropped"); assert!(assistant.get("tool_calls").is_some()); // The user message keeps its content. assert_eq!(body["messages"][0]["content"], json!("hi")); } #[test] fn assistant_tool_call_with_real_content_keeps_content() { let mut body = json!({ "messages": [{ "role": "assistant", "content": "let me check", "tool_calls": [{ "id": "c1", "type": "function", "function": { "name": "f", "arguments": "{}" } }] }] }); adapt_chat_completions_body(&mut body); assert_eq!(body["messages"][0]["content"], json!("let me check")); } #[test] fn assistant_without_tool_calls_keeps_empty_content() { // Only tool-call turns drop content (kimi.py:339-350 guards on // `message.tool_calls`); a plain empty assistant turn is left alone. let mut body = json!({ "messages": [{ "role": "assistant", "content": "" }] }); adapt_chat_completions_body(&mut body); assert_eq!(body["messages"][0]["content"], json!("")); } #[test] fn empty_text_block_list_counts_as_empty_content() { let mut body = json!({ "messages": [{ "role": "assistant", "content": [{ "type": "text", "text": " " }], "tool_calls": [{ "id": "c1", "type": "function", "function": { "name": "f", "arguments": "{}" } }] }] }); adapt_chat_completions_body(&mut body); assert_eq!(body["messages"][0].get("content"), None); } #[test] fn image_block_is_not_empty_content() { let mut body = json!({ "messages": [{ "role": "assistant", "content": [{ "type": "image_url", "image_url": { "url": "data:x" } }], "tool_calls": [{ "id": "c1", "type": "function", "function": { "name": "f", "arguments": "{}" } }] }] }); adapt_chat_completions_body(&mut body); assert!(body["messages"][0].get("content").is_some()); } #[test] fn enum_only_property_gains_inferred_type() { // The Moonshot validator 400s on `{"enum": [...]}` without `type` // (kosong/utils/jsonschema.py:91-96). let mut body = json!({ "tools": [{ "type": "function", "function": { "name": "search", "parameters": { "type": "object", "properties": { "mode": { "enum": ["smart", "full"] }, "count": { "enum": [1, 2, 3] }, "ratio": { "enum": [1, 2.5] }, "nested": { "type": "object", "properties": { "inner": { "enum": ["a"] } } }, "combined": { "anyOf": [{ "type": "string" }] } } } } }] }); adapt_chat_completions_body(&mut body); let props = &body["tools"][0]["function"]["parameters"]["properties"]; assert_eq!(props["mode"]["type"], json!("string")); assert_eq!(props["count"]["type"], json!("integer")); assert_eq!(props["ratio"]["type"], json!("number")); assert_eq!( props["nested"]["properties"]["inner"]["type"], json!("string") ); assert_eq!( props["combined"].get("type"), None, "combinator nodes are left alone" ); } #[test] fn structural_keywords_infer_shape_not_string() { let mut body = json!({ "tools": [{ "type": "function", "function": { "name": "t", "parameters": { "type": "object", "properties": { "obj": { "properties": { "x": { "type": "string" } } }, "arr": { "items": { "type": "string" } }, "num": { "minimum": 0 }, "free": {} } } } }] }); adapt_chat_completions_body(&mut body); let props = &body["tools"][0]["function"]["parameters"]["properties"]; assert_eq!(props["obj"]["type"], json!("object")); assert_eq!(props["arr"]["type"], json!("array")); assert_eq!(props["num"]["type"], json!("number")); assert_eq!(props["free"]["type"], json!("string")); } }