1use crate::client;
26use crate::providers::internal::anthropic_compatible::{
27 AnthropicBaseUrl, impl_dual_dialect_provider,
28};
29use crate::{completion::CompletionError, providers::openai};
30
31pub const GLOBAL_API_BASE_URL: &str = "https://api.moonshot.ai/v1";
36pub const CHINA_API_BASE_URL: &str = "https://api.moonshot.cn/v1";
38pub const ANTHROPIC_API_BASE_URL: &str = "https://api.moonshot.ai/anthropic";
40
41impl_dual_dialect_provider!(
42 ext = MoonshotExt,
43 builder = MoonshotBuilder,
44 anthropic_ext = MoonshotAnthropicExt,
45 anthropic_builder = MoonshotAnthropicBuilder,
46 client_input = String,
47 api_key_env = "MOONSHOT_API_KEY",
48 base_url = GLOBAL_API_BASE_URL,
49 base_url_env = "MOONSHOT_API_BASE",
50 anthropic_provider_name = "moonshot",
51 anthropic_base_url = ANTHROPIC_API_BASE_URL,
52 anthropic_base_url_env = "MOONSHOT_ANTHROPIC_API_BASE",
53);
54
55client::impl_capabilities!(
56 MoonshotExt,
57 completion = CompletionModel<H>,
58 model_listing = MoonshotModelLister<H>,
59);
60
61crate::providers::internal::model_listing::impl_model_lister!(
62 MoonshotModelLister,
69 Client<H>,
70 crate::providers::internal::model_listing::ListModelEntry,
71 "Moonshot",
72 "/models"
73);
74
75impl<H> ClientBuilder<H> {
76 pub fn global(self) -> Self {
77 self.base_url(GLOBAL_API_BASE_URL)
78 }
79
80 pub fn china(self) -> Self {
81 self.base_url(CHINA_API_BASE_URL)
82 }
83}
84
85impl<H> AnthropicClientBuilder<H> {
86 pub fn global(self) -> Self {
87 self.base_url(ANTHROPIC_API_BASE_URL)
88 }
89}
90
91const ANTHROPIC_BASE_URLS: AnthropicBaseUrl = AnthropicBaseUrl::new(
92 &[
93 (GLOBAL_API_BASE_URL, ANTHROPIC_API_BASE_URL),
94 (CHINA_API_BASE_URL, "https://api.moonshot.cn/anthropic"),
95 ],
96 &["/v1", "/v1/"],
97 "/anthropic",
98);
99
100pub const MOONSHOT_CHAT: &str = "moonshot-v1-128k";
106
107pub const KIMI_K2: &str = "kimi-k2";
109
110pub const KIMI_K2_5: &str = "kimi-k2.5";
112
113pub type CompletionModel<H = reqwest::Client> =
115 openai::completion::GenericCompletionModel<MoonshotExt, H>;
116
117impl openai::completion::OpenAICompatibleProvider for MoonshotExt {
118 const PROVIDER_NAME: &'static str = "moonshot";
119
120 type StreamingUsage = openai::Usage;
121
122 const SUPPORTS_RESPONSE_FORMAT: bool = false;
125
126 type Response = openai::CompletionResponse;
127
128 fn prepare_request(
129 &self,
130 request: &mut openai::completion::CompletionRequest,
131 ) -> Result<(), CompletionError> {
132 if matches!(
138 request.tool_choice,
139 Some(openai::completion::ToolChoice::Function { .. })
140 ) {
141 return Err(CompletionError::ProviderError(
142 "Moonshot does not support forcing a specific tool".to_string(),
143 ));
144 }
145
146 if matches!(
149 request.tool_choice,
150 Some(openai::completion::ToolChoice::Required)
151 ) {
152 tracing::warn!(
153 "Moonshot does not support tool_choice=required; coercing to auto with an additional steering message"
154 );
155 request.tool_choice = Some(openai::completion::ToolChoice::Auto);
156 request.messages.push(openai::Message::User {
157 content: vec![openai::UserContent::Text {
158 text: "Please select a tool to handle the current issue.".to_string(),
159 }],
160 name: None,
161 });
162 }
163
164 Ok(())
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::{ANTHROPIC_BASE_URLS, MoonshotExt};
171 use crate::completion::CompletionRequest;
172 use crate::message::{
173 AssistantContent, Message, Reasoning, ToolCall, ToolChoice, ToolFunction,
174 };
175 use crate::providers::openai::completion::{
176 CompletionRequest as OpenAICompletionRequest, OpenAICompatibleProvider, OpenAIRequestParams,
177 };
178
179 fn prepared_body(request: CompletionRequest, model: &str) -> serde_json::Value {
180 let mut request = OpenAICompletionRequest::try_from(OpenAIRequestParams {
181 model: model.to_string(),
182 request,
183 strict_tools: false,
184 tool_result_array_content: false,
185 supports_response_format: MoonshotExt::SUPPORTS_RESPONSE_FORMAT,
186 supports_tools: true,
187 })
188 .expect("request should convert");
189 MoonshotExt
190 .prepare_request(&mut request)
191 .expect("prepare_request should succeed");
192 serde_json::to_value(request).expect("request should serialize")
193 }
194
195 #[test]
196 fn test_client_initialization() {
197 let _client =
198 crate::providers::moonshot::Client::new("dummy-key").expect("Client::new() failed");
199 let _client_from_builder = crate::providers::moonshot::Client::builder()
200 .api_key("dummy-key")
201 .build()
202 .expect("Client::builder() failed");
203 let _anthropic_client = crate::providers::moonshot::AnthropicClient::new("dummy-key")
204 .expect("AnthropicClient::new() failed");
205 let _anthropic_client_from_builder = crate::providers::moonshot::AnthropicClient::builder()
206 .api_key("dummy-key")
207 .build()
208 .expect("AnthropicClient::builder() failed");
209 }
210
211 #[test]
212 fn moonshot_preserves_reasoning_content_in_assistant_history() {
213 let assistant = Message::Assistant {
214 id: None,
215 content: vec![
216 AssistantContent::Reasoning(Reasoning::new("tool planning")),
217 AssistantContent::ToolCall(ToolCall::from_wire(
218 "call_1",
219 ToolFunction {
220 name: "lookup".to_string(),
221 arguments: serde_json::json!({}),
222 },
223 )),
224 ],
225 };
226
227 let request = CompletionRequest {
228 model: Some("kimi-k2-thinking".to_string()),
229 preamble: None,
230 chat_history: vec![assistant],
231 documents: vec![],
232 tools: vec![],
233 temperature: None,
234 max_tokens: None,
235 tool_choice: None,
236 additional_params: None,
237 output_schema: None,
238 record_telemetry_content: false,
239 };
240
241 let body = prepared_body(request, "kimi-k2-thinking");
242 assert_eq!(
243 body["messages"][0]["reasoning_content"],
244 serde_json::json!("tool planning")
245 );
246 }
247
248 #[test]
249 fn moonshot_joins_multiple_reasoning_blocks_with_newline() {
250 let assistant = Message::Assistant {
253 id: None,
254 content: vec![
255 AssistantContent::Reasoning(Reasoning::new("first thought")),
256 AssistantContent::Reasoning(Reasoning::new("second thought")),
257 AssistantContent::Text("done".into()),
258 ],
259 };
260
261 let request = CompletionRequest {
262 model: Some("kimi-k2-thinking".to_string()),
263 preamble: None,
264 chat_history: vec![assistant],
265 documents: vec![],
266 tools: vec![],
267 temperature: None,
268 max_tokens: None,
269 tool_choice: None,
270 additional_params: None,
271 output_schema: None,
272 record_telemetry_content: false,
273 };
274
275 let body = prepared_body(request, "kimi-k2-thinking");
276 assert_eq!(
277 body["messages"][0]["reasoning_content"],
278 serde_json::json!("first thought\nsecond thought")
279 );
280 }
281
282 #[test]
283 fn moonshot_specific_tool_choice_is_rejected() {
284 let request = CompletionRequest {
285 model: Some("kimi-k2.5".to_string()),
286 preamble: None,
287 chat_history: vec![Message::user("Use a tool.")],
288 documents: vec![],
289 tools: vec![],
290 temperature: None,
291 max_tokens: None,
292 tool_choice: Some(ToolChoice::Specific {
293 function_names: vec!["lookup".to_string()],
294 }),
295 additional_params: None,
296 output_schema: None,
297 record_telemetry_content: false,
298 };
299
300 let mut request = OpenAICompletionRequest::try_from(OpenAIRequestParams {
301 model: "kimi-k2.5".to_string(),
302 request,
303 strict_tools: false,
304 tool_result_array_content: false,
305 supports_response_format: MoonshotExt::SUPPORTS_RESPONSE_FORMAT,
306 supports_tools: true,
307 })
308 .expect("request should convert");
309
310 let error = MoonshotExt
311 .prepare_request(&mut request)
312 .expect_err("specific tool choice should be rejected");
313 assert!(error.to_string().contains("specific tool"));
314 }
315
316 #[test]
317 fn moonshot_required_tool_choice_is_coerced() {
318 let request = CompletionRequest {
319 model: Some("kimi-k2.5".to_string()),
320 preamble: None,
321 chat_history: vec![Message::user("Use a tool.")],
322 documents: vec![],
323 tools: vec![],
324 temperature: None,
325 max_tokens: None,
326 tool_choice: Some(ToolChoice::Required),
327 additional_params: None,
328 output_schema: None,
329 record_telemetry_content: false,
330 };
331
332 let body = prepared_body(request, "kimi-k2.5");
333 assert_eq!(body["tool_choice"], "auto");
334 assert_eq!(
335 body["messages"]
336 .as_array()
337 .and_then(|messages| messages.last())
338 .and_then(|message| message.get("content"))
339 .and_then(|content| content.as_str()),
340 Some("Please select a tool to handle the current issue.")
341 );
342 }
343
344 #[test]
345 fn normalize_openai_style_base_to_anthropic_base() {
346 assert_eq!(
347 ANTHROPIC_BASE_URLS
348 .normalize("https://api.moonshot.ai/v1")
349 .as_deref(),
350 Some("https://api.moonshot.ai/anthropic")
351 );
352 assert_eq!(
353 ANTHROPIC_BASE_URLS
354 .normalize("https://api.moonshot.cn/v1")
355 .as_deref(),
356 Some("https://api.moonshot.cn/anthropic")
357 );
358 assert_eq!(
359 ANTHROPIC_BASE_URLS
360 .normalize("https://proxy.example.com/v1")
361 .as_deref(),
362 Some("https://proxy.example.com/anthropic")
363 );
364 }
365
366 #[test]
367 fn normalize_preserves_existing_anthropic_base() {
368 assert_eq!(
369 ANTHROPIC_BASE_URLS
370 .normalize("https://proxy.example.com/anthropic")
371 .as_deref(),
372 Some("https://proxy.example.com/anthropic")
373 );
374 }
375
376 #[test]
377 fn anthropic_primary_override_wins() {
378 let override_url = ANTHROPIC_BASE_URLS.resolve(
379 Some("https://primary.example.com/anthropic"),
380 Some("https://api.moonshot.cn/v1"),
381 );
382
383 assert_eq!(
384 override_url.as_deref(),
385 Some("https://primary.example.com/anthropic")
386 );
387 }
388}