diff --git a/crates/codegen/kigi-models/src/lib.rs b/crates/codegen/kigi-models/src/lib.rs index adb3157..6290697 100644 --- a/crates/codegen/kigi-models/src/lib.rs +++ b/crates/codegen/kigi-models/src/lib.rs @@ -187,6 +187,28 @@ pub struct WireModel { pub supports_video_in: bool, #[serde(default)] pub display_name: Option, + /// `"only"` marks always-thinking models (thinking cannot be disabled). + /// Verified against the live `api.kimi.com/coding/v1/models` response. + #[serde(default)] + pub supports_thinking_type: Option, + /// Selectable thinking-effort levels, present only on models that offer + /// them (e.g. K3). Verified against the live `/models` response. + #[serde(default)] + pub think_efforts: Option, +} + +/// The `think_efforts` object of a `/models` entry. Live wire shape +/// (api.kimi.com, 2026-07): +/// `{"support": true, "valid_efforts": ["low", "high", "max"], +/// "default_effort": "max"}`. +#[derive(Debug, Clone, Default, serde::Deserialize)] +pub struct WireThinkEfforts { + #[serde(default)] + pub support: bool, + #[serde(default)] + pub valid_efforts: Vec, + #[serde(default)] + pub default_effort: Option, } /// `GET {base}/models` response envelope. @@ -203,14 +225,26 @@ impl WireModel { /// - `supports_image_in` → image_in; `supports_video_in` → video_in /// - id starts with `kimi-k2` → thinking + image_in + video_in /// + /// On top of that, the live wire's `supports_thinking_type: "only"` + /// marks a model whose thinking cannot be disabled → always_thinking. + /// /// Returned sorted + deduplicated ([`ModelCapability`]'s `Ord`). pub fn capabilities(&self) -> Vec { - derive_capabilities( + let mut caps = derive_capabilities( &self.id, self.supports_reasoning, self.supports_image_in, self.supports_video_in, - ) + ); + if self.supports_thinking_type.as_deref() == Some("only") { + for cap in [ModelCapability::Thinking, ModelCapability::AlwaysThinking] { + if !caps.contains(&cap) { + caps.push(cap); + } + } + caps.sort(); + } + caps } } @@ -352,6 +386,63 @@ pub fn default_session_summary_model() -> &'static str { mod tests { use super::*; + /// Mirror of the live `api.kimi.com/coding/v1/models` K3 entry + /// (fetched 2026-07-17): `supports_thinking_type: "only"` plus a + /// `think_efforts` block with low/high/max and a max default. + #[test] + fn wire_model_parses_live_k3_think_efforts() { + let json = serde_json::json!({ + "id": "k3", + "created": 1_761_264_000, + "object": "model", + "display_name": "K3", + "type": "model", + "context_length": 1_048_576, + "supports_reasoning": true, + "supports_image_in": true, + "supports_video_in": true, + "supports_thinking_type": "only", + "think_efforts": { + "support": true, + "valid_efforts": ["low", "high", "max"], + "default_effort": "max" + } + }); + let wire: WireModel = serde_json::from_value(json).unwrap(); + let efforts = wire.think_efforts.as_ref().unwrap(); + assert!(efforts.support); + assert_eq!(efforts.valid_efforts, ["low", "high", "max"]); + assert_eq!(efforts.default_effort.as_deref(), Some("max")); + // "only" thinking type forces always_thinking on top of the + // supports_reasoning-derived thinking capability. + let caps = wire.capabilities(); + assert!(caps.contains(&ModelCapability::Thinking)); + assert!(caps.contains(&ModelCapability::AlwaysThinking)); + } + + /// The K2.7 entries carry `supports_thinking_type: "only"` but no + /// `think_efforts` — always-thinking without selectable levels. + #[test] + fn wire_model_without_think_efforts_still_always_thinking() { + let json = serde_json::json!({ + "id": "kimi-for-coding", + "context_length": 262_144, + "supports_reasoning": true, + "supports_image_in": true, + "supports_video_in": true, + "supports_thinking_type": "only" + }); + let wire: WireModel = serde_json::from_value(json).unwrap(); + assert!(wire.think_efforts.is_none()); + let caps = wire.capabilities(); + assert!(caps.contains(&ModelCapability::AlwaysThinking)); + // Sorted + deduplicated invariant holds after the "only" injection. + let mut sorted = caps.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!(caps, sorted); + } + #[test] fn platform_ids_round_trip() { for p in PlatformId::ALL { @@ -463,6 +554,8 @@ mod tests { supports_image_in: false, supports_video_in: false, display_name: None, + supports_thinking_type: None, + think_efforts: None, }, WireModel { id: "moonshot-v1-8k".into(), @@ -471,6 +564,8 @@ mod tests { supports_image_in: false, supports_video_in: false, display_name: None, + supports_thinking_type: None, + think_efforts: None, }, ]; let filtered = filter_allowed_models(PlatformId::MoonshotCn, listing.clone()); diff --git a/crates/codegen/kigi-sampler/src/kimi_compat.rs b/crates/codegen/kigi-sampler/src/kimi_compat.rs index 6bc52eb..381b1e0 100644 --- a/crates/codegen/kigi-sampler/src/kimi_compat.rs +++ b/crates/codegen/kigi-sampler/src/kimi_compat.rs @@ -32,12 +32,21 @@ pub(crate) fn adapt_chat_completions_body(body: &mut Value) { /// Map the OpenAI-style `reasoning_effort` knob onto Kimi's `thinking` /// request field and drop `reasoning_effort` from the wire. /// -/// kimi-cli controls thinking exclusively through the request body's +/// 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; @@ -45,11 +54,22 @@ fn adapt_thinking(body: &mut Value) { let Some(effort) = obj.remove("reasoning_effort") else { return; }; - let enabled = effort.as_str() != Some("none"); - obj.insert( - "thinking".to_owned(), - serde_json::json!({ "type": if enabled { "enabled" } else { "disabled" } }), + 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: @@ -262,12 +282,27 @@ mod tests { #[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" })); + assert_eq!( + body["thinking"], + json!({ "type": "enabled", "effort": "high" }) + ); - // kimi.py:218: "off" (our ReasoningEffort::None) → disabled. + // Canonical `xhigh` is spelled `max` on the Kimi wire (the K3 + // valid_efforts vocabulary is low/high/max). + let mut body = json!({ "model": "k3", "reasoning_effort": "xhigh" }); + 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" })); diff --git a/crates/codegen/kigi-sampler/tests/test_kimi_wire.rs b/crates/codegen/kigi-sampler/tests/test_kimi_wire.rs index 11d2013..695f339 100644 --- a/crates/codegen/kigi-sampler/tests/test_kimi_wire.rs +++ b/crates/codegen/kigi-sampler/tests/test_kimi_wire.rs @@ -382,9 +382,12 @@ async fn request_carries_bearer_kigi_ua_and_kimi_dialect_body() { assert_eq!(body["stream"], json!(true)); assert_eq!(body["stream_options"], json!({ "include_usage": true })); - // -- Thinking mapping (kimi.py:214-223): effort → thinking, no - // reasoning_effort on the wire. - assert_eq!(body["thinking"], json!({ "type": "enabled" })); + // -- Thinking mapping (kimi.py:214-223 + live think_efforts wire): + // effort → thinking {type, effort}, no reasoning_effort on the wire. + assert_eq!( + body["thinking"], + json!({ "type": "enabled", "effort": "high" }) + ); assert_eq!(body.get("reasoning_effort"), None); // -- Message adaptations. diff --git a/crates/codegen/kigi-shell/src/agent/models_fetch.rs b/crates/codegen/kigi-shell/src/agent/models_fetch.rs index 95fe5a5..79d76bc 100644 --- a/crates/codegen/kigi-shell/src/agent/models_fetch.rs +++ b/crates/codegen/kigi-shell/src/agent/models_fetch.rs @@ -303,12 +303,49 @@ fn fetch_one_platform_models( /// platforms — never key values — because raw fetched entries are persisted /// to the models disk cache. Config-file keys are stamped in-memory later by /// `resolve_model_list`'s platform-credentials layer. +/// Map a live `think_efforts` block to catalog effort options. The wire +/// token stays the option id/label (`"max"` → label `"Max"`) so the UI +/// mirrors the server's vocabulary, while the canonical value maps through +/// the [`kigi_sampling_types::ReasoningEffort`] parser (`"max"` → `Xhigh`). +/// Unknown tokens are dropped with a warning rather than inventing a level. +fn think_efforts_to_options( + think: &kigi_models::WireThinkEfforts, +) -> Vec { + think + .valid_efforts + .iter() + .filter_map(|token| { + let value = match token.parse::() { + Ok(v) => v, + Err(error) => { + tracing::warn!(%token, %error, "unknown think_efforts token; dropping"); + return None; + } + }; + let mut label: String = token.clone(); + if let Some(first) = label.get_mut(0..1) { + first.make_ascii_uppercase(); + } + Some(kigi_sampling_types::ReasoningEffortOption { + id: token.clone(), + value, + label, + description: None, + default: think.default_effort.as_deref() == Some(token.as_str()), + }) + }) + .collect() +} + fn platform_wire_model_to_entry( platform: kigi_models::PlatformId, wire: kigi_models::WireModel, base_url: &str, ) -> crate::agent::config::ModelEntryConfig { let capabilities = wire.capabilities(); + // Selectable thinking levels (live wire `think_efforts`, e.g. K3's + // low/high/max). `support: false` or absence both mean "no levels". + let think_efforts = wire.think_efforts.as_ref().filter(|t| t.support); let context_window = std::num::NonZeroU64::new(wire.context_length).unwrap_or_else(|| { tracing::debug!( model = %wire.id, @@ -332,9 +369,13 @@ fn platform_wire_model_to_entry( env_key, api_backend: Default::default(), auth_scheme: None, - reasoning_effort: None, - supports_reasoning_effort: false, - reasoning_efforts: Vec::new(), + reasoning_effort: think_efforts + .and_then(|t| t.default_effort.as_deref()) + .and_then(|s| s.parse().ok()), + supports_reasoning_effort: think_efforts.is_some(), + reasoning_efforts: think_efforts + .map(think_efforts_to_options) + .unwrap_or_default(), capabilities, extra_headers: IndexMap::new(), context_window, @@ -634,6 +675,80 @@ mod tests { assert_eq!(result.model, "actual-model-id"); assert_eq!(result.name.as_deref(), Some("Display Name")); } + /// Live-wire regression: the K3 `/models` entry (api.kimi.com, 2026-07) + /// must land in the catalog with selectable low/high/max efforts and a + /// max default — this is what feeds `/model [effort]` and `/effort`. + #[test] + fn platform_entry_maps_live_k3_think_efforts() { + use kigi_sampling_types::ReasoningEffort; + let wire: kigi_models::WireModel = serde_json::from_value(serde_json::json!({ + "id": "k3", + "display_name": "K3", + "context_length": 1_048_576, + "supports_reasoning": true, + "supports_image_in": true, + "supports_video_in": true, + "supports_thinking_type": "only", + "think_efforts": { + "support": true, + "valid_efforts": ["low", "high", "max"], + "default_effort": "max" + } + })) + .unwrap(); + let entry = platform_wire_model_to_entry( + kigi_models::PlatformId::KimiCode, + wire, + "https://api.kimi.com/coding/v1", + ); + assert!(entry.supports_reasoning_effort); + assert_eq!(entry.reasoning_effort, Some(ReasoningEffort::Xhigh)); + let ids: Vec<&str> = entry + .reasoning_efforts + .iter() + .map(|o| o.id.as_str()) + .collect(); + assert_eq!( + ids, + ["low", "high", "max"], + "wire tokens stay the option ids" + ); + assert_eq!( + entry + .reasoning_efforts + .iter() + .map(|o| o.value) + .collect::>(), + [ + ReasoningEffort::Low, + ReasoningEffort::High, + ReasoningEffort::Xhigh + ], + ); + let max = entry + .reasoning_efforts + .iter() + .find(|o| o.id == "max") + .unwrap(); + assert!(max.default, "max is the server default for K3"); + assert_eq!(max.label, "Max"); + // K2.7-style entries (no think_efforts) stay effort-less. + let plain: kigi_models::WireModel = serde_json::from_value(serde_json::json!({ + "id": "kimi-for-coding", + "context_length": 262_144, + "supports_reasoning": true, + "supports_thinking_type": "only" + })) + .unwrap(); + let entry = platform_wire_model_to_entry( + kigi_models::PlatformId::KimiCode, + plain, + "https://api.kimi.com/coding/v1", + ); + assert!(!entry.supports_reasoning_effort); + assert!(entry.reasoning_efforts.is_empty()); + assert!(entry.reasoning_effort.is_none()); + } #[test] fn parse_reads_reasoning_effort_fields() { use kigi_sampling_types::ReasoningEffort;