Skip to main content

vtcode_llm/providers/
moonshot.rs

1use serde_json::{Map, Value};
2use vtcode_config::constants::{env_vars, models, urls};
3
4use super::openai_compat::{OpenAiCompatCore, OpenAiCompatSpec, SystemPromptPlacement, impl_openai_compat_provider};
5use crate::provider::{LLMError, LLMRequest};
6
7pub struct MoonshotSpec;
8
9fn is_thinking_model(model: &str) -> bool {
10    model.contains("kimi-k3") || model.contains("k2-thinking") || model.contains("kimi-k2-thinking")
11}
12
13/// Map portable effort levels onto Kimi K3's native `low` / `high` / `max` scale.
14fn kimi_k3_effort_value(effort: vtcode_config::types::ReasoningEffortLevel) -> Option<&'static str> {
15    match effort {
16        vtcode_config::types::ReasoningEffortLevel::None | vtcode_config::types::ReasoningEffortLevel::Unknown => None,
17        vtcode_config::types::ReasoningEffortLevel::Minimal | vtcode_config::types::ReasoningEffortLevel::Low => {
18            Some("low")
19        }
20        vtcode_config::types::ReasoningEffortLevel::Medium | vtcode_config::types::ReasoningEffortLevel::High => {
21            Some("high")
22        }
23        vtcode_config::types::ReasoningEffortLevel::XHigh | vtcode_config::types::ReasoningEffortLevel::Max => {
24            Some("max")
25        }
26    }
27}
28
29impl OpenAiCompatSpec for MoonshotSpec {
30    const NAME: &'static str = "Moonshot";
31    const KEY: &'static str = "moonshot";
32    const API_KEY_ENV: &'static str = "MOONSHOT_API_KEY";
33    const DEFAULT_MODEL: &'static str = models::moonshot::DEFAULT_MODEL;
34    const DEFAULT_BASE_URL: &'static str = urls::MOONSHOT_API_BASE;
35    const BASE_URL_ENV: Option<&'static str> = Some(env_vars::MOONSHOT_BASE_URL);
36    const LISTED_MODELS: &'static [&'static str] = models::moonshot::SUPPORTED_MODELS;
37    // Moonshot publishes new official aliases and preview slugs faster than VT Code's
38    // curated picker list is refreshed, so let the upstream API be the source of truth
39    // for model identifiers and keep local validation focused on request shape.
40    const VALIDATION_ALLOWLIST: Option<&'static [&'static str]> = None;
41
42    const SYSTEM_PROMPT: SystemPromptPlacement = SystemPromptPlacement::Omitted;
43    const INCLUDE_TOP_P: bool = false;
44    const SUPPRESS_SAMPLING_WHEN_REASONING: bool = false;
45
46    fn normalize_model(model: String) -> String {
47        model.trim().to_string()
48    }
49
50    fn insert_reasoning(
51        _core: &OpenAiCompatCore<Self>,
52        request: &LLMRequest,
53        payload: &mut Map<String, Value>,
54    ) -> Result<(), LLMError> {
55        // Kimi K3 accepts only `low`, `high`, and `max`. Kimi K2.7 Code omits
56        // effort fields (always-on native thinking).
57        if let Some(effort) = request.reasoning_effort
58            && is_thinking_model(&request.model)
59            && let Some(value) = kimi_k3_effort_value(effort)
60        {
61            payload.insert("reasoning_effort".to_string(), Value::String(value.to_string()));
62        }
63        Ok(())
64    }
65}
66
67impl_openai_compat_provider!(MoonshotProvider, MoonshotSpec, {
68    fn supports_reasoning(&self, model: &str) -> bool {
69        is_thinking_model(model)
70    }
71
72    fn supports_reasoning_effort(&self, model: &str) -> bool {
73        is_thinking_model(model)
74    }
75});
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use crate::provider::{Message, ToolChoice};
81    use std::sync::Arc;
82    use vtcode_config::types::ReasoningEffortLevel;
83
84    fn provider() -> MoonshotProvider {
85        MoonshotProvider::from_config(
86            Some("test-key".to_string()),
87            Some("kimi-k2.7".to_string()),
88            Some("https://example.test/v1".to_string()),
89            None,
90            None,
91            None,
92            None,
93        )
94    }
95
96    fn base_request(model: &str) -> LLMRequest {
97        LLMRequest {
98            messages: vec![Message::user("hello".to_string())].into(),
99            system_prompt: Some(Arc::from("system guidance")),
100            model: model.to_string(),
101            max_tokens: Some(512),
102            temperature: Some(0.5),
103            stream: true,
104            tool_choice: Some(ToolChoice::Auto),
105            ..Default::default()
106        }
107    }
108
109    #[test]
110    fn golden_payload_basic_shape() {
111        let payload = provider().core.convert_request(&base_request("kimi-k2.7")).unwrap();
112
113        assert_eq!(payload["model"], "kimi-k2.7");
114        // Moonshot does not inject the system prompt into messages.
115        let messages = payload["messages"].as_array().unwrap();
116        assert_eq!(messages.len(), 1);
117        assert_eq!(messages[0]["role"], "user");
118        assert_eq!(messages[0]["content"], "hello");
119        assert_eq!(payload["max_tokens"], 512);
120        assert_eq!(payload["temperature"], 0.5);
121        assert_eq!(payload["stream"], true);
122        assert!(payload.get("stream_options").is_none());
123        assert!(payload.get("top_p").is_none());
124        assert!(payload.get("reasoning_effort").is_none());
125        assert_eq!(payload["tool_choice"], "auto");
126    }
127
128    #[test]
129    fn golden_payload_reasoning_effort_for_thinking_models() {
130        let mut request = base_request("kimi-k2-thinking");
131        request.reasoning_effort = Some(ReasoningEffortLevel::Low);
132        let payload = provider().core.convert_request(&request).unwrap();
133        assert_eq!(payload["reasoning_effort"], "low");
134        // Sampling parameters are not suppressed for reasoning requests.
135        assert_eq!(payload["temperature"], 0.5);
136
137        let mut request = base_request("kimi-k2.7");
138        request.reasoning_effort = Some(ReasoningEffortLevel::Low);
139        let payload = provider().core.convert_request(&request).unwrap();
140        assert!(payload.get("reasoning_effort").is_none());
141    }
142
143    #[test]
144    fn kimi_k3_effort_maps_to_native_scale() {
145        for (effort, expected) in [
146            (ReasoningEffortLevel::Minimal, "low"),
147            (ReasoningEffortLevel::Low, "low"),
148            (ReasoningEffortLevel::Medium, "high"),
149            (ReasoningEffortLevel::High, "high"),
150            (ReasoningEffortLevel::XHigh, "max"),
151            (ReasoningEffortLevel::Max, "max"),
152        ] {
153            let mut request = base_request("kimi-k3");
154            request.reasoning_effort = Some(effort);
155            let payload = provider().core.convert_request(&request).unwrap();
156            assert_eq!(payload["reasoning_effort"], expected);
157        }
158
159        // Kimi K2.7 Code always uses native thinking; effort fields are omitted.
160        let mut request = base_request("kimi-k2.7-code");
161        request.reasoning_effort = Some(ReasoningEffortLevel::Max);
162        let payload = provider().core.convert_request(&request).unwrap();
163        assert!(payload.get("reasoning_effort").is_none());
164    }
165}