Skip to main content

rig/providers/
moonshot.rs

1//! Moonshot AI (Kimi) API client and Rig integration
2//!
3//! # Example
4//! ```no_run
5//! use rig::providers::moonshot;
6//! use rig::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::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::{
26    self, BearerAuth, Capabilities, Capable, DebugExt, Nothing, Provider, ProviderBuilder,
27    ProviderClient,
28};
29use crate::http_client::HttpClientExt;
30use crate::providers::anthropic::client::{
31    AnthropicBuilder as AnthropicCompatBuilder, AnthropicKey, finish_anthropic_builder,
32};
33use crate::providers::openai::send_compatible_streaming_request;
34use crate::streaming::StreamingCompletionResponse;
35use crate::{
36    completion::{self, CompletionError, CompletionRequest},
37    json_utils,
38    providers::openai,
39};
40use crate::{http_client, message};
41use serde::{Deserialize, Serialize};
42use serde_json::{Value, json};
43use tracing::{Instrument, info_span};
44
45// ================================================================
46// Main Moonshot Client
47// ================================================================
48/// Global OpenAI-compatible base URL.
49pub const GLOBAL_API_BASE_URL: &str = "https://api.moonshot.ai/v1";
50/// China OpenAI-compatible base URL.
51pub const CHINA_API_BASE_URL: &str = "https://api.moonshot.cn/v1";
52/// Anthropic-compatible base URL.
53pub const ANTHROPIC_API_BASE_URL: &str = "https://api.moonshot.ai/anthropic";
54
55#[derive(Debug, Default, Clone, Copy)]
56pub struct MoonshotExt;
57#[derive(Debug, Default, Clone, Copy)]
58pub struct MoonshotBuilder;
59#[derive(Debug, Default, Clone)]
60pub struct MoonshotAnthropicBuilder {
61    anthropic: AnthropicCompatBuilder,
62}
63#[derive(Debug, Default, Clone, Copy)]
64pub struct MoonshotAnthropicExt;
65
66type MoonshotApiKey = BearerAuth;
67
68impl Provider for MoonshotExt {
69    type Builder = MoonshotBuilder;
70
71    const VERIFY_PATH: &'static str = "/models";
72}
73
74impl Provider for MoonshotAnthropicExt {
75    type Builder = MoonshotAnthropicBuilder;
76
77    const VERIFY_PATH: &'static str = "/v1/models";
78}
79
80impl DebugExt for MoonshotExt {}
81impl DebugExt for MoonshotAnthropicExt {}
82
83impl ProviderBuilder for MoonshotBuilder {
84    type Extension<H>
85        = MoonshotExt
86    where
87        H: HttpClientExt;
88    type ApiKey = MoonshotApiKey;
89
90    const BASE_URL: &'static str = GLOBAL_API_BASE_URL;
91
92    fn build<H>(
93        _builder: &crate::client::ClientBuilder<Self, Self::ApiKey, H>,
94    ) -> http_client::Result<Self::Extension<H>>
95    where
96        H: HttpClientExt,
97    {
98        Ok(MoonshotExt)
99    }
100}
101
102impl ProviderBuilder for MoonshotAnthropicBuilder {
103    type Extension<H>
104        = MoonshotAnthropicExt
105    where
106        H: HttpClientExt;
107    type ApiKey = AnthropicKey;
108
109    const BASE_URL: &'static str = ANTHROPIC_API_BASE_URL;
110
111    fn build<H>(
112        _builder: &crate::client::ClientBuilder<Self, Self::ApiKey, H>,
113    ) -> http_client::Result<Self::Extension<H>>
114    where
115        H: HttpClientExt,
116    {
117        Ok(MoonshotAnthropicExt)
118    }
119
120    fn finish<H>(
121        &self,
122        builder: client::ClientBuilder<Self, AnthropicKey, H>,
123    ) -> http_client::Result<client::ClientBuilder<Self, AnthropicKey, H>> {
124        finish_anthropic_builder(&self.anthropic, builder)
125    }
126}
127
128impl<H> Capabilities<H> for MoonshotExt {
129    type Completion = Capable<CompletionModel<H>>;
130    type Embeddings = Nothing;
131    type Transcription = Nothing;
132    type ModelListing = Nothing;
133    #[cfg(feature = "image")]
134    type ImageGeneration = Nothing;
135    #[cfg(feature = "audio")]
136    type AudioGeneration = Nothing;
137}
138
139impl<H> Capabilities<H> for MoonshotAnthropicExt {
140    type Completion =
141        Capable<super::anthropic::completion::GenericCompletionModel<MoonshotAnthropicExt, H>>;
142    type Embeddings = Nothing;
143    type Transcription = Nothing;
144    type ModelListing = Nothing;
145    #[cfg(feature = "image")]
146    type ImageGeneration = Nothing;
147    #[cfg(feature = "audio")]
148    type AudioGeneration = Nothing;
149}
150
151pub type Client<H = reqwest::Client> = client::Client<MoonshotExt, H>;
152pub type ClientBuilder<H = reqwest::Client> =
153    client::ClientBuilder<MoonshotBuilder, MoonshotApiKey, H>;
154pub type AnthropicClient<H = reqwest::Client> = client::Client<MoonshotAnthropicExt, H>;
155pub type AnthropicClientBuilder<H = reqwest::Client> =
156    client::ClientBuilder<MoonshotAnthropicBuilder, AnthropicKey, H>;
157
158impl ProviderClient for Client {
159    type Input = String;
160    type Error = crate::client::ProviderClientError;
161
162    /// Create a new Moonshot client from the `MOONSHOT_API_KEY` environment variable.
163    fn from_env() -> Result<Self, Self::Error> {
164        let api_key = crate::client::required_env_var("MOONSHOT_API_KEY")?;
165        let mut builder = Self::builder().api_key(&api_key);
166        if let Some(base_url) = crate::client::optional_env_var("MOONSHOT_API_BASE")? {
167            builder = builder.base_url(base_url);
168        }
169        builder.build().map_err(Into::into)
170    }
171
172    fn from_val(input: Self::Input) -> Result<Self, Self::Error> {
173        Self::new(&input).map_err(Into::into)
174    }
175}
176
177impl ProviderClient for AnthropicClient {
178    type Input = String;
179    type Error = crate::client::ProviderClientError;
180
181    fn from_env() -> Result<Self, Self::Error> {
182        let api_key = crate::client::required_env_var("MOONSHOT_API_KEY")?;
183        let mut builder = Self::builder().api_key(api_key);
184        if let Some(base_url) =
185            anthropic_base_override("MOONSHOT_ANTHROPIC_API_BASE", "MOONSHOT_API_BASE")?
186        {
187            builder = builder.base_url(base_url);
188        }
189        builder.build().map_err(Into::into)
190    }
191
192    fn from_val(input: Self::Input) -> Result<Self, Self::Error> {
193        Self::builder().api_key(input).build().map_err(Into::into)
194    }
195}
196
197impl<H> ClientBuilder<H> {
198    pub fn global(self) -> Self {
199        self.base_url(GLOBAL_API_BASE_URL)
200    }
201
202    pub fn china(self) -> Self {
203        self.base_url(CHINA_API_BASE_URL)
204    }
205}
206
207impl<H> AnthropicClientBuilder<H> {
208    pub fn global(self) -> Self {
209        self.base_url(ANTHROPIC_API_BASE_URL)
210    }
211
212    pub fn anthropic_version(self, anthropic_version: &str) -> Self {
213        self.over_ext(|mut ext| {
214            ext.anthropic.anthropic_version = anthropic_version.into();
215            ext
216        })
217    }
218
219    pub fn anthropic_betas(self, anthropic_betas: &[&str]) -> Self {
220        self.over_ext(|mut ext| {
221            ext.anthropic
222                .anthropic_betas
223                .extend(anthropic_betas.iter().copied().map(String::from));
224            ext
225        })
226    }
227
228    pub fn anthropic_beta(self, anthropic_beta: &str) -> Self {
229        self.over_ext(|mut ext| {
230            ext.anthropic.anthropic_betas.push(anthropic_beta.into());
231            ext
232        })
233    }
234}
235
236impl super::anthropic::completion::AnthropicCompatibleProvider for MoonshotAnthropicExt {
237    const PROVIDER_NAME: &'static str = "moonshot";
238
239    fn default_max_tokens(_model: &str) -> Option<u64> {
240        Some(4096)
241    }
242}
243
244fn anthropic_base_override(
245    primary_env: &'static str,
246    fallback_env: &'static str,
247) -> crate::client::ProviderClientResult<Option<String>> {
248    let primary = crate::client::optional_env_var(primary_env)?;
249    let fallback = crate::client::optional_env_var(fallback_env)?;
250
251    Ok(resolve_anthropic_base_override(
252        primary.as_deref(),
253        fallback.as_deref(),
254    ))
255}
256
257fn resolve_anthropic_base_override(
258    primary: Option<&str>,
259    fallback: Option<&str>,
260) -> Option<String> {
261    primary
262        .map(str::to_owned)
263        .or_else(|| fallback.and_then(normalize_anthropic_base_url))
264}
265
266fn normalize_anthropic_base_url(base_url: &str) -> Option<String> {
267    if base_url.contains("/anthropic") {
268        return Some(base_url.to_owned());
269    }
270
271    let mut url = url::Url::parse(base_url).ok()?;
272    if !matches!(url.path(), "/v1" | "/v1/") {
273        return None;
274    }
275    url.set_path("/anthropic");
276    Some(url.to_string())
277}
278
279#[derive(Debug, Deserialize)]
280struct ApiErrorResponse {
281    error: MoonshotError,
282}
283
284#[derive(Debug, Deserialize)]
285struct MoonshotError {
286    message: String,
287}
288
289#[derive(Debug, Deserialize)]
290#[serde(untagged)]
291enum ApiResponse<T> {
292    Ok(T),
293    Err(ApiErrorResponse),
294}
295
296// ================================================================
297// Moonshot Completion API
298// ================================================================
299
300/// Moonshot v1 128K context model (legacy)
301pub const MOONSHOT_CHAT: &str = "moonshot-v1-128k";
302
303/// Kimi K2 — Mixture-of-Experts model (1T total params, 32B active)
304pub const KIMI_K2: &str = "kimi-k2";
305
306/// Kimi K2.5 — Native multimodal agentic model with 256K context
307pub const KIMI_K2_5: &str = "kimi-k2.5";
308
309#[derive(Debug, Serialize, Deserialize)]
310pub(super) struct MoonshotCompletionRequest {
311    model: String,
312    pub messages: Vec<Value>,
313    #[serde(skip_serializing_if = "Option::is_none")]
314    temperature: Option<f64>,
315    #[serde(skip_serializing_if = "Vec::is_empty")]
316    tools: Vec<openai::ToolDefinition>,
317    #[serde(skip_serializing_if = "Option::is_none")]
318    max_tokens: Option<u64>,
319    #[serde(skip_serializing_if = "Option::is_none")]
320    tool_choice: Option<crate::providers::openai::completion::ToolChoice>,
321    #[serde(flatten, skip_serializing_if = "Option::is_none")]
322    pub additional_params: Option<serde_json::Value>,
323}
324
325impl TryFrom<(&str, CompletionRequest)> for MoonshotCompletionRequest {
326    type Error = CompletionError;
327
328    fn try_from((model, req): (&str, CompletionRequest)) -> Result<Self, Self::Error> {
329        if req.output_schema.is_some() {
330            tracing::warn!("Structured outputs currently not supported for Moonshot");
331        }
332        let model = req.model.clone().unwrap_or_else(|| model.to_string());
333        // Build up the order of messages (context, chat_history, prompt)
334        let mut partial_history = vec![];
335        if let Some(docs) = req.normalized_documents() {
336            partial_history.push(docs);
337        }
338        partial_history.extend(req.chat_history);
339
340        let mut full_history: Vec<Value> = match &req.preamble {
341            Some(preamble) => vec![serde_json::to_value(openai::Message::system(preamble))?],
342            None => vec![],
343        };
344
345        full_history.extend(moonshot_history_values(partial_history)?);
346
347        let mut tool_choice = None;
348        let mut tool_choice_required = false;
349        if let Some(choice) = req.tool_choice.clone() {
350            match choice {
351                message::ToolChoice::Required => {
352                    tool_choice_required = true;
353                    tool_choice = Some(crate::providers::openai::completion::ToolChoice::Auto);
354                }
355                other => {
356                    tool_choice = Some(crate::providers::openai::ToolChoice::try_from(other)?);
357                }
358            }
359        }
360
361        if tool_choice_required {
362            tracing::warn!(
363                "Moonshot does not support tool_choice=required; coercing to auto with an additional steering message"
364            );
365            full_history.push(json!({
366                "role": "user",
367                "content": "Please select a tool to handle the current issue."
368            }));
369        }
370
371        Ok(Self {
372            model: model.to_string(),
373            messages: full_history,
374            temperature: req.temperature,
375            max_tokens: req.max_tokens,
376            tools: req
377                .tools
378                .clone()
379                .into_iter()
380                .map(openai::ToolDefinition::from)
381                .collect::<Vec<_>>(),
382            tool_choice,
383            additional_params: req.additional_params,
384        })
385    }
386}
387
388fn moonshot_history_values(history: Vec<message::Message>) -> Result<Vec<Value>, CompletionError> {
389    let mut result = Vec::new();
390
391    for message in history {
392        match message {
393            message::Message::Assistant { id: _, content } => {
394                if let Some(value) = moonshot_assistant_message_value(content)? {
395                    result.push(value);
396                }
397            }
398            other => {
399                result.extend(
400                    Vec::<openai::Message>::try_from(other)?
401                        .into_iter()
402                        .map(serde_json::to_value)
403                        .collect::<Result<Vec<_>, _>>()?,
404                );
405            }
406        }
407    }
408
409    Ok(result)
410}
411
412fn moonshot_assistant_message_value(
413    content: crate::OneOrMany<message::AssistantContent>,
414) -> Result<Option<Value>, CompletionError> {
415    let mut text_content = Vec::new();
416    let mut tool_calls = Vec::new();
417    let mut reasoning_parts = Vec::new();
418
419    for item in content {
420        match item {
421            message::AssistantContent::Text(text) => {
422                text_content.push(openai::AssistantContent::Text { text: text.text });
423            }
424            message::AssistantContent::ToolCall(tool_call) => {
425                tool_calls.push(openai::ToolCall::from(tool_call));
426            }
427            message::AssistantContent::Reasoning(reasoning) => {
428                let display = reasoning.display_text();
429                if !display.is_empty() {
430                    reasoning_parts.push(display);
431                }
432            }
433            message::AssistantContent::Image(_) => {
434                return Err(CompletionError::ProviderError(
435                    "Moonshot does not support assistant image content in chat history".into(),
436                ));
437            }
438        }
439    }
440
441    if text_content.is_empty() && tool_calls.is_empty() && reasoning_parts.is_empty() {
442        return Ok(None);
443    }
444
445    let content_value = if text_content.is_empty() {
446        Value::String(String::new())
447    } else {
448        serde_json::to_value(text_content)?
449    };
450
451    let mut object = serde_json::Map::from_iter([
452        ("role".to_string(), Value::String("assistant".to_string())),
453        ("content".to_string(), content_value),
454    ]);
455
456    if !tool_calls.is_empty() {
457        object.insert("tool_calls".to_string(), serde_json::to_value(tool_calls)?);
458    }
459
460    if !reasoning_parts.is_empty() {
461        object.insert(
462            "reasoning_content".to_string(),
463            Value::String(reasoning_parts.join("\n")),
464        );
465    }
466
467    Ok(Some(Value::Object(object)))
468}
469
470#[derive(Clone)]
471pub struct CompletionModel<T = reqwest::Client> {
472    client: Client<T>,
473    pub model: String,
474}
475
476impl<T> CompletionModel<T> {
477    pub fn new(client: Client<T>, model: impl Into<String>) -> Self {
478        Self {
479            client,
480            model: model.into(),
481        }
482    }
483}
484
485impl<T> completion::CompletionModel for CompletionModel<T>
486where
487    T: HttpClientExt + Clone + Default + std::fmt::Debug + Send + 'static,
488{
489    type Response = openai::CompletionResponse;
490    type StreamingResponse = openai::StreamingCompletionResponse;
491
492    type Client = Client<T>;
493
494    fn make(client: &Self::Client, model: impl Into<String>) -> Self {
495        Self::new(client.clone(), model)
496    }
497
498    async fn completion(
499        &self,
500        completion_request: CompletionRequest,
501    ) -> Result<completion::CompletionResponse<openai::CompletionResponse>, CompletionError> {
502        let span = if tracing::Span::current().is_disabled() {
503            info_span!(
504                target: "rig::completions",
505                "chat",
506                gen_ai.operation.name = "chat",
507                gen_ai.provider.name = "moonshot",
508                gen_ai.request.model = self.model,
509                gen_ai.system_instructions = tracing::field::Empty,
510                gen_ai.response.id = tracing::field::Empty,
511                gen_ai.response.model = tracing::field::Empty,
512                gen_ai.usage.output_tokens = tracing::field::Empty,
513                gen_ai.usage.input_tokens = tracing::field::Empty,
514                gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
515            )
516        } else {
517            tracing::Span::current()
518        };
519
520        span.record("gen_ai.system_instructions", &completion_request.preamble);
521
522        let request =
523            MoonshotCompletionRequest::try_from((self.model.as_ref(), completion_request))?;
524
525        if tracing::enabled!(tracing::Level::TRACE) {
526            tracing::trace!(target: "rig::completions",
527                "MoonShot completion request: {}",
528                serde_json::to_string_pretty(&request)?
529            );
530        }
531
532        let body = serde_json::to_vec(&request)?;
533        let req = self
534            .client
535            .post("/chat/completions")?
536            .body(body)
537            .map_err(http_client::Error::from)?;
538
539        let async_block = async move {
540            let response = self.client.send::<_, bytes::Bytes>(req).await?;
541
542            let status = response.status();
543            let response_body = response.into_body().into_future().await?.to_vec();
544
545            if status.is_success() {
546                match serde_json::from_slice::<ApiResponse<openai::CompletionResponse>>(
547                    &response_body,
548                )? {
549                    ApiResponse::Ok(response) => {
550                        let span = tracing::Span::current();
551                        span.record("gen_ai.response.id", response.id.clone());
552                        span.record("gen_ai.response.model", response.model.clone());
553                        if let Some(ref usage) = response.usage {
554                            span.record("gen_ai.usage.input_tokens", usage.prompt_tokens);
555                            span.record(
556                                "gen_ai.usage.output_tokens",
557                                usage.total_tokens - usage.prompt_tokens,
558                            );
559                        }
560                        if tracing::enabled!(tracing::Level::TRACE) {
561                            tracing::trace!(target: "rig::completions",
562                                "MoonShot completion response: {}",
563                                serde_json::to_string_pretty(&response)?
564                            );
565                        }
566                        response.try_into()
567                    }
568                    ApiResponse::Err(err) => Err(CompletionError::ProviderError(err.error.message)),
569                }
570            } else {
571                Err(CompletionError::ProviderError(
572                    String::from_utf8_lossy(&response_body).to_string(),
573                ))
574            }
575        };
576
577        async_block.instrument(span).await
578    }
579
580    async fn stream(
581        &self,
582        request: CompletionRequest,
583    ) -> Result<StreamingCompletionResponse<Self::StreamingResponse>, CompletionError> {
584        let span = if tracing::Span::current().is_disabled() {
585            info_span!(
586                target: "rig::completions",
587                "chat_streaming",
588                gen_ai.operation.name = "chat_streaming",
589                gen_ai.provider.name = "moonshot",
590                gen_ai.request.model = self.model,
591                gen_ai.system_instructions = tracing::field::Empty,
592                gen_ai.response.id = tracing::field::Empty,
593                gen_ai.response.model = tracing::field::Empty,
594                gen_ai.usage.output_tokens = tracing::field::Empty,
595                gen_ai.usage.input_tokens = tracing::field::Empty,
596                gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
597            )
598        } else {
599            tracing::Span::current()
600        };
601
602        span.record("gen_ai.system_instructions", &request.preamble);
603        let mut request = MoonshotCompletionRequest::try_from((self.model.as_ref(), request))?;
604
605        let params = json_utils::merge(
606            request.additional_params.unwrap_or(serde_json::json!({})),
607            serde_json::json!({"stream": true, "stream_options": {"include_usage": true} }),
608        );
609
610        request.additional_params = Some(params);
611
612        if tracing::enabled!(tracing::Level::TRACE) {
613            tracing::trace!(target: "rig::completions",
614                "MoonShot streaming completion request: {}",
615                serde_json::to_string_pretty(&request)?
616            );
617        }
618
619        let body = serde_json::to_vec(&request)?;
620        let req = self
621            .client
622            .post("/chat/completions")?
623            .body(body)
624            .map_err(http_client::Error::from)?;
625
626        send_compatible_streaming_request(self.client.clone(), req)
627            .instrument(span)
628            .await
629    }
630}
631
632#[derive(Default, Debug, Deserialize, Serialize)]
633pub enum ToolChoice {
634    None,
635    #[default]
636    Auto,
637}
638
639impl TryFrom<message::ToolChoice> for ToolChoice {
640    type Error = CompletionError;
641
642    fn try_from(value: message::ToolChoice) -> Result<Self, Self::Error> {
643        let res = match value {
644            message::ToolChoice::None => Self::None,
645            message::ToolChoice::Auto => Self::Auto,
646            choice => {
647                return Err(CompletionError::ProviderError(format!(
648                    "Unsupported tool choice type: {choice:?}"
649                )));
650            }
651        };
652
653        Ok(res)
654    }
655}
656#[cfg(test)]
657mod tests {
658    use super::{
659        MoonshotCompletionRequest, normalize_anthropic_base_url, resolve_anthropic_base_override,
660    };
661    use crate::completion::CompletionRequest;
662    use crate::message::{
663        AssistantContent, Message, Reasoning, ToolCall, ToolChoice, ToolFunction,
664    };
665
666    #[test]
667    fn test_client_initialization() {
668        let _client =
669            crate::providers::moonshot::Client::new("dummy-key").expect("Client::new() failed");
670        let _client_from_builder = crate::providers::moonshot::Client::builder()
671            .api_key("dummy-key")
672            .build()
673            .expect("Client::builder() failed");
674        let _anthropic_client = crate::providers::moonshot::AnthropicClient::new("dummy-key")
675            .expect("AnthropicClient::new() failed");
676        let _anthropic_client_from_builder = crate::providers::moonshot::AnthropicClient::builder()
677            .api_key("dummy-key")
678            .build()
679            .expect("AnthropicClient::builder() failed");
680    }
681
682    #[test]
683    fn moonshot_preserves_reasoning_content_in_assistant_history() {
684        let assistant = Message::Assistant {
685            id: None,
686            content: crate::OneOrMany::many(vec![
687                AssistantContent::Reasoning(Reasoning::new("tool planning")),
688                AssistantContent::ToolCall(ToolCall {
689                    id: "call_1".to_string(),
690                    call_id: None,
691                    function: ToolFunction {
692                        name: "lookup".to_string(),
693                        arguments: serde_json::json!({}),
694                    },
695                    signature: None,
696                    additional_params: None,
697                }),
698            ])
699            .expect("assistant content"),
700        };
701
702        let request = CompletionRequest {
703            model: Some("kimi-k2-thinking".to_string()),
704            preamble: None,
705            chat_history: crate::OneOrMany::one(assistant),
706            documents: vec![],
707            tools: vec![],
708            temperature: None,
709            max_tokens: None,
710            tool_choice: None,
711            additional_params: None,
712            output_schema: None,
713        };
714
715        let converted =
716            MoonshotCompletionRequest::try_from(("kimi-k2-thinking", request)).expect("convert");
717        let assistant = converted
718            .messages
719            .first()
720            .and_then(|value| value.as_object())
721            .expect("assistant message");
722
723        assert_eq!(
724            assistant
725                .get("reasoning_content")
726                .and_then(|value| value.as_str()),
727            Some("tool planning")
728        );
729    }
730
731    #[test]
732    fn moonshot_required_tool_choice_is_coerced() {
733        let request = CompletionRequest {
734            model: Some("kimi-k2.5".to_string()),
735            preamble: None,
736            chat_history: crate::OneOrMany::one(Message::user("Use a tool.")),
737            documents: vec![],
738            tools: vec![],
739            temperature: None,
740            max_tokens: None,
741            tool_choice: Some(ToolChoice::Required),
742            additional_params: None,
743            output_schema: None,
744        };
745
746        let converted =
747            MoonshotCompletionRequest::try_from(("kimi-k2.5", request)).expect("convert");
748        assert!(matches!(
749            converted.tool_choice,
750            Some(crate::providers::openai::completion::ToolChoice::Auto)
751        ));
752        assert_eq!(
753            converted
754                .messages
755                .last()
756                .and_then(|value| value.get("content"))
757                .and_then(|value| value.as_str()),
758            Some("Please select a tool to handle the current issue.")
759        );
760    }
761
762    #[test]
763    fn normalize_openai_style_base_to_anthropic_base() {
764        assert_eq!(
765            normalize_anthropic_base_url("https://api.moonshot.ai/v1").as_deref(),
766            Some("https://api.moonshot.ai/anthropic")
767        );
768        assert_eq!(
769            normalize_anthropic_base_url("https://api.moonshot.cn/v1").as_deref(),
770            Some("https://api.moonshot.cn/anthropic")
771        );
772        assert_eq!(
773            normalize_anthropic_base_url("https://proxy.example.com/v1").as_deref(),
774            Some("https://proxy.example.com/anthropic")
775        );
776    }
777
778    #[test]
779    fn normalize_preserves_existing_anthropic_base() {
780        assert_eq!(
781            normalize_anthropic_base_url("https://proxy.example.com/anthropic").as_deref(),
782            Some("https://proxy.example.com/anthropic")
783        );
784    }
785
786    #[test]
787    fn anthropic_primary_override_wins() {
788        let override_url = resolve_anthropic_base_override(
789            Some("https://primary.example.com/anthropic"),
790            Some("https://api.moonshot.cn/v1"),
791        );
792
793        assert_eq!(
794            override_url.as_deref(),
795            Some("https://primary.example.com/anthropic")
796        );
797    }
798}