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
25use super::{
26    CatalogModel, CatalogProviderKind, CatalogSource, Modality, PricingSummary, ReasoningSupport,
27};
28
29/// Documented reasoning capability for an exact Kimi model id.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum KimiReasoningCapability {
32    /// Documented named-effort control (top-level `reasoning_effort`).
33    Effort {
34        supported: &'static [ReasoningLevel],
35        default_level: Option<ReasoningLevel>,
36        /// Whether reasoning can be disabled. `false` → `Off` must be
37        /// rejected, never silently omitted/downgraded.
38        can_disable: bool,
39    },
40    /// Thinking is always on; no effort control and no disable switch
41    /// (the live API rejects `thinking.type = "disabled"`).
42    AlwaysThinking,
43    /// Thinking on by default; only a documented on/off toggle
44    /// (`thinking.type = "enabled" | "disabled"`), no graduated effort.
45    ToggleableThinking,
46}
47
48/// Exact-id capability lookup — never substring-based. Unknown ids return
49/// `None` and fail closed at validation time.
50pub fn kimi_static_capability(model_id: &str) -> Option<KimiReasoningCapability> {
51    use ReasoningLevel::*;
52    match model_id {
53        "kimi-k3" => Some(KimiReasoningCapability::Effort {
54            supported: &[Low, High, Max],
55            default_level: Some(Max),
56            can_disable: false,
57        }),
58        "kimi-k2.7-code" | "kimi-k2.7-code-highspeed" => {
59            Some(KimiReasoningCapability::AlwaysThinking)
60        }
61        "kimi-k2.6" | "kimi-k2.5" => Some(KimiReasoningCapability::ToggleableThinking),
62        _ => None,
63    }
64}
65
66// ─── Managed Kimi Code (OAuth) catalog ───────────────────────────────────────
67//
68// Exact ids served by the managed endpoint `https://api.kimi.com/coding/v1`
69// (`GET /models`), verified against the official Kimi Code CLI v0.31.1
70// provisioning output. This is a conservative allowlist: unknown ids fail
71// closed at route resolution, mirroring the xai-auth/google-gemini pattern.
72
73/// One managed Kimi Code model row (id as served by `/models`).
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct KimiCodeModelDescriptor {
76    pub id: &'static str,
77    pub label: &'static str,
78    pub context_tokens: Option<u64>,
79}
80
81pub const KIMI_CODE_TEXT_MODELS: &[KimiCodeModelDescriptor] = &[
82    KimiCodeModelDescriptor {
83        id: "k3",
84        label: "Kimi K3",
85        context_tokens: Some(1_048_576),
86    },
87    KimiCodeModelDescriptor {
88        id: "k3-256k",
89        label: "Kimi K3 256k",
90        context_tokens: Some(262_144),
91    },
92    KimiCodeModelDescriptor {
93        id: "kimi-for-coding",
94        label: "Kimi K2.7 Coding",
95        context_tokens: Some(262_144),
96    },
97    KimiCodeModelDescriptor {
98        id: "kimi-for-coding-highspeed",
99        label: "Kimi K2.7 Coding Highspeed",
100        context_tokens: Some(262_144),
101    },
102];
103
104/// Exact-id lookup — never substring-based. Unknown ids return `None` and
105/// fail closed at route resolution.
106pub fn kimi_code_model(id: &str) -> Option<&'static KimiCodeModelDescriptor> {
107    KIMI_CODE_TEXT_MODELS.iter().find(|model| model.id == id)
108}
109
110/// Reasoning capability for an exact managed Kimi Code model id.
111///
112/// Evidence: the managed `/models` payload provisioned by the official CLI —
113/// `k3`/`k3-256k` carry `think_efforts {low, high, max}` with default `high`
114/// and `always_thinking`; the `kimi-for-coding*` pair are `always_thinking`
115/// with no effort control. Reasoning is not disableable on any managed id.
116pub fn kimi_code_capability(model_id: &str) -> Option<KimiReasoningCapability> {
117    use ReasoningLevel::*;
118    match model_id {
119        "k3" | "k3-256k" => Some(KimiReasoningCapability::Effort {
120            supported: &[Low, High, Max],
121            default_level: Some(High),
122            can_disable: false,
123        }),
124        "kimi-for-coding" | "kimi-for-coding-highspeed" => {
125            Some(KimiReasoningCapability::AlwaysThinking)
126        }
127        _ => None,
128    }
129}
130
131/// Static catalog rows for the managed Kimi Code OAuth provider (TUI/model
132/// pickers). All managed ids reason, so `GenericOpenAi` is honest here.
133pub fn kimi_code_static_catalog_models() -> Vec<CatalogModel> {
134    KIMI_CODE_TEXT_MODELS
135        .iter()
136        .map(|descriptor| CatalogModel {
137            provider_key: "kimi-code".into(),
138            provider_name: "Kimi Code".into(),
139            provider_kind: CatalogProviderKind::Generic {
140                key: "kimi-code".into(),
141            },
142            id: descriptor.id.into(),
143            label: Some(descriptor.label.into()),
144            context_tokens: descriptor.context_tokens,
145            max_output_tokens: None,
146            input_modalities: vec![Modality::Text],
147            pricing: PricingSummary::default(),
148            reasoning: ReasoningSupport::GenericOpenAi,
149            source: CatalogSource::StaticFallback,
150        })
151        .collect()
152}
153
154/// Exact wire label for a validated Kimi effort level.
155///
156/// Only levels present in a capability row's `supported` slice ever reach
157/// this; anything else returns `None` and the field is omitted (provider
158/// default), never guessed.
159fn kimi_effort_label(level: ReasoningLevel) -> Option<&'static str> {
160    match level {
161        ReasoningLevel::Low => Some("low"),
162        ReasoningLevel::High => Some("high"),
163        ReasoningLevel::Max => Some("max"),
164        _ => None,
165    }
166}
167
168/// Apply the documented Kimi reasoning fields to a Chat Completions body.
169///
170/// Levels are validated upstream by `validation::validate_reasoning_mutation`
171/// against the same capability table; this function is deliberately
172/// omission-biased for anything unexpected (omitted field = provider
173/// default), so it can never send an undocumented value.
174pub fn apply_kimi_reasoning_params(
175    body: &mut Map<String, Value>,
176    model_id: &str,
177    level: ReasoningLevel,
178) {
179    // Static (Moonshot platform) and managed (Kimi Code OAuth) tables are
180    // disjoint id namespaces (`kimi-k3` vs `k3`), so this chain can never
181    // pick the wrong capability row.
182    match kimi_static_capability(model_id).or_else(|| kimi_code_capability(model_id)) {
183        Some(KimiReasoningCapability::Effort { supported, .. }) => {
184            // Adaptive = provider default via field omission.
185            if supported.contains(&level) {
186                if let Some(label) = kimi_effort_label(level) {
187                    body.insert("reasoning_effort".to_string(), json!(label));
188                }
189            }
190        }
191        Some(KimiReasoningCapability::ToggleableThinking) => {
192            if level == ReasoningLevel::Off {
193                body.insert("thinking".to_string(), json!({ "type": "disabled" }));
194            }
195            // Everything else: omit → provider default (thinking enabled).
196        }
197        // Always-thinking models and unknown ids: nothing is expressible on
198        // the wire; send no reasoning fields at all.
199        Some(KimiReasoningCapability::AlwaysThinking) | None => {}
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use ReasoningLevel::*;
207
208    #[test]
209    fn k3_capability_is_exact_low_high_max_default_max_no_disable() {
210        let Some(KimiReasoningCapability::Effort {
211            supported,
212            default_level,
213            can_disable,
214        }) = kimi_static_capability("kimi-k3")
215        else {
216            panic!("kimi-k3 must have documented effort capability");
217        };
218        assert_eq!(supported, &[Low, High, Max]);
219        assert_eq!(default_level, Some(Max));
220        assert!(!can_disable);
221    }
222
223    #[test]
224    fn k27_code_family_is_always_thinking() {
225        for id in ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"] {
226            assert_eq!(
227                kimi_static_capability(id),
228                Some(KimiReasoningCapability::AlwaysThinking),
229                "{id}"
230            );
231        }
232    }
233
234    #[test]
235    fn k26_and_k25_are_toggleable() {
236        for id in ["kimi-k2.6", "kimi-k2.5"] {
237            assert_eq!(
238                kimi_static_capability(id),
239                Some(KimiReasoningCapability::ToggleableThinking),
240                "{id}"
241            );
242        }
243    }
244
245    #[test]
246    fn unknown_ids_fail_closed() {
247        assert_eq!(kimi_static_capability("kimi-k9"), None);
248        assert_eq!(kimi_static_capability(""), None);
249        // Never substring-based.
250        assert_eq!(kimi_static_capability("kimi-k3-preview"), None);
251    }
252
253    #[test]
254    fn k3_body_carries_exact_reasoning_effort() {
255        for (level, label) in [(Low, "low"), (High, "high"), (Max, "max")] {
256            let mut body = Map::new();
257            apply_kimi_reasoning_params(&mut body, "kimi-k3", level);
258            assert_eq!(body.get("reasoning_effort"), Some(&json!(label)));
259            assert!(!body.contains_key("thinking"));
260        }
261    }
262
263    #[test]
264    fn k3_adaptive_omits_the_field() {
265        let mut body = Map::new();
266        apply_kimi_reasoning_params(&mut body, "kimi-k3", Adaptive);
267        assert!(body.is_empty());
268    }
269
270    #[test]
271    fn k3_unsupported_levels_are_omitted_never_guessed() {
272        // Validation rejects these upstream; the wire layer must still never
273        // invent a value for them.
274        for level in [Off, Medium, XHigh, Ultra, UltraCode] {
275            let mut body = Map::new();
276            apply_kimi_reasoning_params(&mut body, "kimi-k3", level);
277            assert!(body.is_empty(), "{level:?} must not serialize");
278        }
279    }
280
281    #[test]
282    fn k26_off_disables_thinking_and_other_levels_omit() {
283        let mut body = Map::new();
284        apply_kimi_reasoning_params(&mut body, "kimi-k2.6", Off);
285        assert_eq!(body.get("thinking"), Some(&json!({"type": "disabled"})));
286        assert!(!body.contains_key("reasoning_effort"));
287        for level in [Adaptive, Low, Medium, High, Max] {
288            let mut body = Map::new();
289            apply_kimi_reasoning_params(&mut body, "kimi-k2.6", level);
290            assert!(body.is_empty(), "{level:?} must omit all fields");
291        }
292    }
293
294    #[test]
295    fn always_thinking_and_unknown_send_nothing() {
296        for id in ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k9"] {
297            for level in [Off, Adaptive, Low, High, Max] {
298                let mut body = Map::new();
299                apply_kimi_reasoning_params(&mut body, id, level);
300                assert!(body.is_empty(), "{id} {level:?} must send nothing");
301            }
302        }
303    }
304
305    // ─── Managed Kimi Code (OAuth) catalog ───────────────────────────────────
306
307    #[test]
308    fn kimi_code_k3_family_has_effort_low_high_max_default_high_no_disable() {
309        for id in ["k3", "k3-256k"] {
310            let Some(KimiReasoningCapability::Effort {
311                supported,
312                default_level,
313                can_disable,
314            }) = kimi_code_capability(id)
315            else {
316                panic!("{id} must have managed effort capability");
317            };
318            assert_eq!(supported, &[Low, High, Max]);
319            assert_eq!(default_level, Some(High));
320            assert!(!can_disable, "{id} reasoning must not be disableable");
321        }
322    }
323
324    #[test]
325    fn kimi_code_coding_family_is_always_thinking_and_unknown_fails_closed() {
326        for id in ["kimi-for-coding", "kimi-for-coding-highspeed"] {
327            assert_eq!(
328                kimi_code_capability(id),
329                Some(KimiReasoningCapability::AlwaysThinking),
330                "{id}"
331            );
332        }
333        assert_eq!(kimi_code_capability("k3-preview"), None);
334        // Static-platform ids never leak into the managed table.
335        assert_eq!(kimi_code_capability("kimi-k3"), None);
336        assert_eq!(kimi_code_capability(""), None);
337    }
338
339    #[test]
340    fn managed_and_static_id_namespaces_are_disjoint() {
341        for descriptor in KIMI_CODE_TEXT_MODELS {
342            assert!(
343                kimi_static_capability(descriptor.id).is_none(),
344                "{} must not collide with the static Moonshot table",
345                descriptor.id
346            );
347            assert!(kimi_code_model(descriptor.id).is_some());
348        }
349    }
350
351    #[test]
352    fn managed_k3_body_carries_exact_reasoning_effort_via_shared_apply() {
353        for (level, label) in [(Low, "low"), (High, "high"), (Max, "max")] {
354            let mut body = Map::new();
355            apply_kimi_reasoning_params(&mut body, "k3", level);
356            assert_eq!(body.get("reasoning_effort"), Some(&json!(label)));
357            assert!(!body.contains_key("thinking"));
358        }
359        // Adaptive/Off/unknown-levels omit — provider default, never guessed.
360        for level in [Adaptive, Off, Medium, XHigh] {
361            let mut body = Map::new();
362            apply_kimi_reasoning_params(&mut body, "k3-256k", level);
363            assert!(body.is_empty(), "{level:?} must not serialize");
364        }
365    }
366
367    #[test]
368    fn managed_always_thinking_sends_nothing() {
369        for id in ["kimi-for-coding", "kimi-for-coding-highspeed"] {
370            for level in [Off, Adaptive, Low, High, Max] {
371                let mut body = Map::new();
372                apply_kimi_reasoning_params(&mut body, id, level);
373                assert!(body.is_empty(), "{id} {level:?} must send nothing");
374            }
375        }
376    }
377
378    #[test]
379    fn kimi_code_catalog_rows_are_managed_and_prefixed_consistently() {
380        let models = kimi_code_static_catalog_models();
381        assert_eq!(models.len(), KIMI_CODE_TEXT_MODELS.len());
382        for model in &models {
383            assert_eq!(model.provider_key, "kimi-code");
384            assert!(model.context_tokens.is_some());
385            assert!(model.label.is_some());
386        }
387    }
388}