1use 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
45pub const GLOBAL_API_BASE_URL: &str = "https://api.moonshot.ai/v1";
50pub const CHINA_API_BASE_URL: &str = "https://api.moonshot.cn/v1";
52pub 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 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
296pub const MOONSHOT_CHAT: &str = "moonshot-v1-128k";
302
303pub const KIMI_K2: &str = "kimi-k2";
305
306pub 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 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}