Skip to main content

agent_engine/runtime/openai/catalog/
kimi.rs

1//! Exact-id reasoning capability for the Kimi (Moonshot AI) static provider.
2//!
3//! Evidence: official Moonshot platform docs
4//! (platform.kimi.ai/docs/guide/use-thinking-effort,
5//! platform.kimi.ai/docs/guide/use-kimi-k2-thinking-model), verified live
6//! against `api.moonshot.ai`:
7//!
8//! * `kimi-k3` — always reasons; graduated control via the **top-level**
9//!   `reasoning_effort` request field with exactly `"low"` / `"high"` /
10//!   `"max"` (provider default `"max"`). Reasoning cannot be disabled.
11//! * `kimi-k2.7-code` / `kimi-k2.7-code-highspeed` — thinking is always on;
12//!   the K2.x `thinking` field accepts only `{"type":"enabled"}` and the
13//!   live API errors on `"disabled"`. No effort control exists.
14//! * `kimi-k2.6` / `kimi-k2.5` — thinking on by default, disableable via
15//!   `{"thinking":{"type":"disabled"}}`. No graduated effort control.
16//!
17//! The Moonshot API silently ignores unknown top-level fields and unknown
18//! `reasoning_effort` values (verified live), so local validation is the only
19//! honest gate: an unsupported level must be rejected here, never sent and
20//! silently swallowed upstream.
21
22use agent_core::reasoning::ReasoningLevel;
23use serde_json::{json, Map, Value};
24
25/// Documented reasoning capability for an exact Kimi model id.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum KimiReasoningCapability {
28    /// Documented named-effort control (top-level `reasoning_effort`).
29    Effort {
30        supported: &'static [ReasoningLevel],
31        default_level: Option<ReasoningLevel>,
32        /// Whether reasoning can be disabled. `false` → `Off` must be
33        /// rejected, never silently omitted/downgraded.
34        can_disable: bool,
35    },
36    /// Thinking is always on; no effort control and no disable switch
37    /// (the live API rejects `thinking.type = "disabled"`).
38    AlwaysThinking,
39    /// Thinking on by default; only a documented on/off toggle
40    /// (`thinking.type = "enabled" | "disabled"`), no graduated effort.
41    ToggleableThinking,
42}
43
44/// Exact-id capability lookup — never substring-based. Unknown ids return
45/// `None` and fail closed at validation time.
46pub fn kimi_static_capability(model_id: &str) -> Option<KimiReasoningCapability> {
47    use ReasoningLevel::*;
48    match model_id {
49        "kimi-k3" => Some(KimiReasoningCapability::Effort {
50            supported: &[Low, High, Max],
51            default_level: Some(Max),
52            can_disable: false,
53        }),
54        "kimi-k2.7-code" | "kimi-k2.7-code-highspeed" => {
55            Some(KimiReasoningCapability::AlwaysThinking)
56        }
57        "kimi-k2.6" | "kimi-k2.5" => Some(KimiReasoningCapability::ToggleableThinking),
58        _ => None,
59    }
60}
61
62/// Exact wire label for a validated Kimi effort level.
63///
64/// Only levels present in a capability row's `supported` slice ever reach
65/// this; anything else returns `None` and the field is omitted (provider
66/// default), never guessed.
67fn kimi_effort_label(level: ReasoningLevel) -> Option<&'static str> {
68    match level {
69        ReasoningLevel::Low => Some("low"),
70        ReasoningLevel::High => Some("high"),
71        ReasoningLevel::Max => Some("max"),
72        _ => None,
73    }
74}
75
76/// Apply the documented Kimi reasoning fields to a Chat Completions body.
77///
78/// Levels are validated upstream by `validation::validate_reasoning_mutation`
79/// against the same capability table; this function is deliberately
80/// omission-biased for anything unexpected (omitted field = provider
81/// default), so it can never send an undocumented value.
82pub fn apply_kimi_reasoning_params(
83    body: &mut Map<String, Value>,
84    model_id: &str,
85    level: ReasoningLevel,
86) {
87    match kimi_static_capability(model_id) {
88        Some(KimiReasoningCapability::Effort { supported, .. }) => {
89            // Adaptive = provider default via field omission.
90            if supported.contains(&level) {
91                if let Some(label) = kimi_effort_label(level) {
92                    body.insert("reasoning_effort".to_string(), json!(label));
93                }
94            }
95        }
96        Some(KimiReasoningCapability::ToggleableThinking) => {
97            if level == ReasoningLevel::Off {
98                body.insert("thinking".to_string(), json!({ "type": "disabled" }));
99            }
100            // Everything else: omit → provider default (thinking enabled).
101        }
102        // Always-thinking models and unknown ids: nothing is expressible on
103        // the wire; send no reasoning fields at all.
104        Some(KimiReasoningCapability::AlwaysThinking) | None => {}
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use ReasoningLevel::*;
112
113    #[test]
114    fn k3_capability_is_exact_low_high_max_default_max_no_disable() {
115        let Some(KimiReasoningCapability::Effort {
116            supported,
117            default_level,
118            can_disable,
119        }) = kimi_static_capability("kimi-k3")
120        else {
121            panic!("kimi-k3 must have documented effort capability");
122        };
123        assert_eq!(supported, &[Low, High, Max]);
124        assert_eq!(default_level, Some(Max));
125        assert!(!can_disable);
126    }
127
128    #[test]
129    fn k27_code_family_is_always_thinking() {
130        for id in ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"] {
131            assert_eq!(
132                kimi_static_capability(id),
133                Some(KimiReasoningCapability::AlwaysThinking),
134                "{id}"
135            );
136        }
137    }
138
139    #[test]
140    fn k26_and_k25_are_toggleable() {
141        for id in ["kimi-k2.6", "kimi-k2.5"] {
142            assert_eq!(
143                kimi_static_capability(id),
144                Some(KimiReasoningCapability::ToggleableThinking),
145                "{id}"
146            );
147        }
148    }
149
150    #[test]
151    fn unknown_ids_fail_closed() {
152        assert_eq!(kimi_static_capability("kimi-k9"), None);
153        assert_eq!(kimi_static_capability(""), None);
154        // Never substring-based.
155        assert_eq!(kimi_static_capability("kimi-k3-preview"), None);
156    }
157
158    #[test]
159    fn k3_body_carries_exact_reasoning_effort() {
160        for (level, label) in [(Low, "low"), (High, "high"), (Max, "max")] {
161            let mut body = Map::new();
162            apply_kimi_reasoning_params(&mut body, "kimi-k3", level);
163            assert_eq!(body.get("reasoning_effort"), Some(&json!(label)));
164            assert!(!body.contains_key("thinking"));
165        }
166    }
167
168    #[test]
169    fn k3_adaptive_omits_the_field() {
170        let mut body = Map::new();
171        apply_kimi_reasoning_params(&mut body, "kimi-k3", Adaptive);
172        assert!(body.is_empty());
173    }
174
175    #[test]
176    fn k3_unsupported_levels_are_omitted_never_guessed() {
177        // Validation rejects these upstream; the wire layer must still never
178        // invent a value for them.
179        for level in [Off, Medium, XHigh, Ultra, UltraCode] {
180            let mut body = Map::new();
181            apply_kimi_reasoning_params(&mut body, "kimi-k3", level);
182            assert!(body.is_empty(), "{level:?} must not serialize");
183        }
184    }
185
186    #[test]
187    fn k26_off_disables_thinking_and_other_levels_omit() {
188        let mut body = Map::new();
189        apply_kimi_reasoning_params(&mut body, "kimi-k2.6", Off);
190        assert_eq!(body.get("thinking"), Some(&json!({"type": "disabled"})));
191        assert!(!body.contains_key("reasoning_effort"));
192        for level in [Adaptive, Low, Medium, High, Max] {
193            let mut body = Map::new();
194            apply_kimi_reasoning_params(&mut body, "kimi-k2.6", level);
195            assert!(body.is_empty(), "{level:?} must omit all fields");
196        }
197    }
198
199    #[test]
200    fn always_thinking_and_unknown_send_nothing() {
201        for id in ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k9"] {
202            for level in [Off, Adaptive, Low, High, Max] {
203                let mut body = Map::new();
204                apply_kimi_reasoning_params(&mut body, id, level);
205                assert!(body.is_empty(), "{id} {level:?} must send nothing");
206            }
207        }
208    }
209}