Skip to main content

rig_core/providers/
moonshot.rs

1//! Moonshot AI (Kimi) API client and Rig integration
2//!
3//! # Example
4//! ```no_run
5//! use rig_core::providers::moonshot;
6//! use rig_core::client::CompletionClient;
7//!
8//! let client = moonshot::Client::new("YOUR_API_KEY").expect("Failed to build client");
9//!
10//! let kimi_model = client.completion_model(moonshot::KIMI_K2_5);
11//! ```
12//!
13//! # Custom base URL
14//! The default base URL is `https://api.moonshot.ai/v1`. For China access,
15//! use `https://api.moonshot.cn/v1`:
16//! ```no_run
17//! use rig_core::providers::moonshot;
18//!
19//! let client = moonshot::Client::builder()
20//!     .api_key("YOUR_API_KEY")
21//!     .base_url("https://api.moonshot.ai/v1")
22//!     .build()
23//!     .expect("Failed to build Moonshot client");
24//! ```
25use crate::client;
26use crate::providers::internal::anthropic_compatible::{
27    AnthropicBaseUrl, impl_dual_dialect_provider,
28};
29use crate::{completion::CompletionError, providers::openai};
30
31// ================================================================
32// Main Moonshot Client
33// ================================================================
34/// Global OpenAI-compatible base URL.
35pub const GLOBAL_API_BASE_URL: &str = "https://api.moonshot.ai/v1";
36/// China OpenAI-compatible base URL.
37pub const CHINA_API_BASE_URL: &str = "https://api.moonshot.cn/v1";
38/// Anthropic-compatible base URL.
39pub 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    /// [`ModelLister`](crate::client::ModelLister) implementation for the
63    /// Moonshot API (`GET /models`).
64    ///
65    /// Moonshot documents the OpenAI-style `{"object":"list","data":[…]}`
66    /// envelope; its entries carry extra fields (`context_length`,
67    /// `supports_image_in`, …) that the shared entry ignores.
68    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
100// ================================================================
101// Moonshot Completion API
102// ================================================================
103
104/// Moonshot v1 128K context model (legacy)
105pub const MOONSHOT_CHAT: &str = "moonshot-v1-128k";
106
107/// Kimi K2 — Mixture-of-Experts model (1T total params, 32B active)
108pub const KIMI_K2: &str = "kimi-k2";
109
110/// Kimi K2.5 — Native multimodal agentic model with 256K context
111pub const KIMI_K2_5: &str = "kimi-k2.5";
112
113/// Moonshot completion model, driven by the shared OpenAI Chat Completions path.
114pub 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    // Moonshot's API rejects the `json_schema` response format; keep the
123    // pre-migration behavior of dropping `output_schema` with a warning.
124    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        // Moonshot only supports `auto`/`none` tool choices. Forcing one
133        // specific tool has no workaround; fail fast like the pre-migration
134        // conversion did (on main, `openai::ToolChoice::try_from` returned
135        // "Provider doesn't support only using specific tools" for every
136        // `ToolChoice::Specific`, single- or multi-name).
137        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        // Moonshot does not support `tool_choice: "required"`; coerce it to
147        // `auto` and steer the model with an extra user message instead.
148        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        // A replayed assistant turn carrying two distinct reasoning blocks must
251        // keep them newline-separated on the wire, not glued together.
252        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}