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 type Rerank = Nothing;
138}
139
140impl<H> Capabilities<H> for MoonshotAnthropicExt {
141 type Completion =
142 Capable<super::anthropic::completion::GenericCompletionModel<MoonshotAnthropicExt, H>>;
143 type Embeddings = Nothing;
144 type Transcription = Nothing;
145 type ModelListing = Nothing;
146 #[cfg(feature = "image")]
147 type ImageGeneration = Nothing;
148 #[cfg(feature = "audio")]
149 type AudioGeneration = Nothing;
150 type Rerank = Nothing;
151}
152
153pub type Client<H = reqwest::Client> = client::Client<MoonshotExt, H>;
154pub type ClientBuilder<H = crate::markers::Missing> =
155 client::ClientBuilder<MoonshotBuilder, MoonshotApiKey, H>;
156pub type AnthropicClient<H = reqwest::Client> = client::Client<MoonshotAnthropicExt, H>;
157pub type AnthropicClientBuilder<H = crate::markers::Missing> =
158 client::ClientBuilder<MoonshotAnthropicBuilder, AnthropicKey, H>;
159
160impl ProviderClient for Client {
161 type Input = String;
162 type Error = crate::client::ProviderClientError;
163
164 fn from_env() -> Result<Self, Self::Error> {
166 let api_key = crate::client::required_env_var("MOONSHOT_API_KEY")?;
167 let mut builder = Self::builder().api_key(&api_key);
168 if let Some(base_url) = crate::client::optional_env_var("MOONSHOT_API_BASE")? {
169 builder = builder.base_url(base_url);
170 }
171 builder.build().map_err(Into::into)
172 }
173
174 fn from_val(input: Self::Input) -> Result<Self, Self::Error> {
175 Self::new(&input).map_err(Into::into)
176 }
177}
178
179impl ProviderClient for AnthropicClient {
180 type Input = String;
181 type Error = crate::client::ProviderClientError;
182
183 fn from_env() -> Result<Self, Self::Error> {
184 let api_key = crate::client::required_env_var("MOONSHOT_API_KEY")?;
185 let mut builder = Self::builder().api_key(api_key);
186 if let Some(base_url) =
187 anthropic_base_override("MOONSHOT_ANTHROPIC_API_BASE", "MOONSHOT_API_BASE")?
188 {
189 builder = builder.base_url(base_url);
190 }
191 builder.build().map_err(Into::into)
192 }
193
194 fn from_val(input: Self::Input) -> Result<Self, Self::Error> {
195 Self::builder().api_key(input).build().map_err(Into::into)
196 }
197}
198
199impl<H> ClientBuilder<H> {
200 pub fn global(self) -> Self {
201 self.base_url(GLOBAL_API_BASE_URL)
202 }
203
204 pub fn china(self) -> Self {
205 self.base_url(CHINA_API_BASE_URL)
206 }
207}
208
209impl<H> AnthropicClientBuilder<H> {
210 pub fn global(self) -> Self {
211 self.base_url(ANTHROPIC_API_BASE_URL)
212 }
213
214 pub fn anthropic_version(self, anthropic_version: &str) -> Self {
215 self.over_ext(|mut ext| {
216 ext.anthropic.anthropic_version = anthropic_version.into();
217 ext
218 })
219 }
220
221 pub fn anthropic_betas(self, anthropic_betas: &[&str]) -> Self {
222 self.over_ext(|mut ext| {
223 ext.anthropic
224 .anthropic_betas
225 .extend(anthropic_betas.iter().copied().map(String::from));
226 ext
227 })
228 }
229
230 pub fn anthropic_beta(self, anthropic_beta: &str) -> Self {
231 self.over_ext(|mut ext| {
232 ext.anthropic.anthropic_betas.push(anthropic_beta.into());
233 ext
234 })
235 }
236}
237
238impl super::anthropic::completion::AnthropicCompatibleProvider for MoonshotAnthropicExt {
239 const PROVIDER_NAME: &'static str = "moonshot";
240
241 fn default_max_tokens(_model: &str) -> Option<u64> {
242 Some(4096)
243 }
244}
245
246fn anthropic_base_override(
247 primary_env: &'static str,
248 fallback_env: &'static str,
249) -> crate::client::ProviderClientResult<Option<String>> {
250 let primary = crate::client::optional_env_var(primary_env)?;
251 let fallback = crate::client::optional_env_var(fallback_env)?;
252
253 Ok(resolve_anthropic_base_override(
254 primary.as_deref(),
255 fallback.as_deref(),
256 ))
257}
258
259fn resolve_anthropic_base_override(
260 primary: Option<&str>,
261 fallback: Option<&str>,
262) -> Option<String> {
263 primary
264 .map(str::to_owned)
265 .or_else(|| fallback.and_then(normalize_anthropic_base_url))
266}
267
268fn normalize_anthropic_base_url(base_url: &str) -> Option<String> {
269 if base_url.contains("/anthropic") {
270 return Some(base_url.to_owned());
271 }
272
273 let mut url = url::Url::parse(base_url).ok()?;
274 if !matches!(url.path(), "/v1" | "/v1/") {
275 return None;
276 }
277 url.set_path("/anthropic");
278 Some(url.to_string())
279}
280
281#[derive(Debug, Deserialize)]
282struct ApiErrorResponse {
283 error: MoonshotError,
284}
285
286#[derive(Debug, Deserialize)]
287struct MoonshotError {
288 message: String,
289}
290
291#[derive(Debug, Deserialize)]
292#[serde(untagged)]
293enum ApiResponse<T> {
294 Ok(T),
295 Err(ApiErrorResponse),
296}
297
298pub const MOONSHOT_CHAT: &str = "moonshot-v1-128k";
304
305pub const KIMI_K2: &str = "kimi-k2";
307
308pub const KIMI_K2_5: &str = "kimi-k2.5";
310
311#[derive(Debug, Serialize, Deserialize)]
312pub(super) struct MoonshotCompletionRequest {
313 model: String,
314 pub messages: Vec<Value>,
315 #[serde(skip_serializing_if = "Option::is_none")]
316 temperature: Option<f64>,
317 #[serde(skip_serializing_if = "Vec::is_empty")]
318 tools: Vec<openai::ToolDefinition>,
319 #[serde(skip_serializing_if = "Option::is_none")]
320 max_tokens: Option<u64>,
321 #[serde(skip_serializing_if = "Option::is_none")]
322 tool_choice: Option<crate::providers::openai::completion::ToolChoice>,
323 #[serde(flatten, skip_serializing_if = "Option::is_none")]
324 pub additional_params: Option<serde_json::Value>,
325}
326
327impl TryFrom<(&str, CompletionRequest)> for MoonshotCompletionRequest {
328 type Error = CompletionError;
329
330 fn try_from((model, req): (&str, CompletionRequest)) -> Result<Self, Self::Error> {
331 let chat_history = req.chat_history_with_documents();
332 if req.output_schema.is_some() {
333 tracing::warn!("Structured outputs currently not supported for Moonshot");
334 }
335 let model = req.model.clone().unwrap_or_else(|| model.to_string());
336 let mut partial_history = vec![];
338 partial_history.extend(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}