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