vtcode_llm/providers/
moonshot.rs1use 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
13fn 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 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 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 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 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 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}