Skip to main content

vtcode_core/llm/providers/
minimax.rs

1use crate::config::TimeoutsConfig;
2use crate::config::constants::{env_vars, models, urls};
3use crate::config::core::{AnthropicConfig, ModelConfig, PromptCachingConfig};
4use crate::llm::provider::{LLMError, LLMProvider, LLMRequest, LLMResponse, LLMStream, Message};
5use async_trait::async_trait;
6use std::env;
7
8use super::anthropic::AnthropicProvider;
9use super::common::{ensure_model, impl_llm_client, resolve_model};
10
11pub struct MinimaxProvider {
12    inner: AnthropicProvider,
13    model: String,
14}
15
16impl MinimaxProvider {
17    pub fn new(api_key: String) -> Self {
18        Self::from_config(
19            Some(api_key),
20            Some(models::minimax::DEFAULT_MODEL.to_string()),
21            None,
22            None,
23            None,
24            None,
25            None,
26        )
27    }
28
29    pub fn with_model(api_key: String, model: String) -> Self {
30        Self::from_config(Some(api_key), Some(model), None, None, None, None, None)
31    }
32
33    pub fn new_with_client(
34        api_key: String,
35        model: String,
36        http_client: reqwest::Client,
37        base_url: String,
38        timeouts: TimeoutsConfig,
39    ) -> Self {
40        let resolved_model = resolve_model(Some(model), models::minimax::DEFAULT_MODEL);
41        let resolved_base = resolve_minimax_base_url(Some(base_url));
42
43        Self {
44            inner: AnthropicProvider::new_with_client(
45                api_key,
46                resolved_model.clone(),
47                http_client,
48                resolved_base,
49                timeouts,
50            ),
51            model: resolved_model,
52        }
53    }
54
55    pub fn from_config(
56        api_key: Option<String>,
57        model: Option<String>,
58        base_url: Option<String>,
59        prompt_cache: Option<PromptCachingConfig>,
60        timeouts: Option<TimeoutsConfig>,
61        anthropic: Option<AnthropicConfig>,
62        model_behavior: Option<ModelConfig>,
63    ) -> Self {
64        let resolved_model = resolve_model(model, models::minimax::DEFAULT_MODEL);
65        let resolved_base = resolve_minimax_base_url(base_url);
66
67        Self {
68            inner: AnthropicProvider::from_config(
69                api_key,
70                Some(resolved_model.clone()),
71                Some(resolved_base),
72                prompt_cache,
73                timeouts,
74                anthropic,
75                model_behavior,
76            ),
77            model: resolved_model,
78        }
79    }
80}
81
82fn resolve_minimax_base_url(base_url: Option<String>) -> String {
83    fn sanitize(value: &str) -> Option<String> {
84        let trimmed = value.trim();
85        if trimmed.is_empty() {
86            None
87        } else {
88            Some(trimmed.trim_end_matches('/').to_string())
89        }
90    }
91
92    fn is_official_minimax_host(url: &str) -> bool {
93        let lower = url.to_ascii_lowercase();
94        [
95            "://api.minimax.io",
96            "://platform.minimax.io",
97            "api.minimax.io",
98            "platform.minimax.io",
99        ]
100        .iter()
101        .any(|marker| lower.contains(marker))
102    }
103
104    let resolved = base_url
105        .and_then(|value| sanitize(&value))
106        .or_else(|| {
107            env::var(env_vars::MINIMAX_BASE_URL)
108                .ok()
109                .and_then(|value| sanitize(&value))
110        })
111        .or_else(|| {
112            env::var(env_vars::ANTHROPIC_BASE_URL)
113                .ok()
114                .and_then(|value| sanitize(&value))
115        })
116        .or_else(|| sanitize(urls::MINIMAX_API_BASE))
117        .unwrap_or_else(|| urls::MINIMAX_API_BASE.trim_end_matches('/').to_string());
118
119    let mut normalized = resolved;
120
121    if normalized.ends_with("/messages") {
122        normalized = normalized
123            .trim_end_matches("/messages")
124            .trim_end_matches('/')
125            .to_string();
126    }
127
128    if let Some(pos) = normalized.find("/v1/") {
129        normalized = normalized[..pos + 3].to_string();
130    }
131
132    let mut without_v1 = normalized.trim_end_matches('/').to_string();
133    if without_v1.ends_with("/v1") {
134        without_v1 = without_v1
135            .trim_end_matches("/v1")
136            .trim_end_matches('/')
137            .to_string();
138    }
139
140    if is_official_minimax_host(&without_v1)
141        && !without_v1.to_ascii_lowercase().contains("/anthropic")
142    {
143        without_v1 = format!("{}/anthropic", without_v1.trim_end_matches('/'));
144    }
145
146    format!("{}/v1", without_v1.trim_end_matches('/'))
147}
148
149#[async_trait]
150impl LLMProvider for MinimaxProvider {
151    fn name(&self) -> &str {
152        "minimax"
153    }
154
155    fn supports_streaming(&self) -> bool {
156        self.inner.supports_streaming()
157    }
158
159    fn supports_reasoning(&self, model: &str) -> bool {
160        self.inner.supports_reasoning(model)
161    }
162
163    fn supports_reasoning_effort(&self, model: &str) -> bool {
164        self.inner.supports_reasoning_effort(model)
165    }
166
167    fn supports_tools(&self, model: &str) -> bool {
168        self.inner.supports_tools(model)
169    }
170
171    fn supports_parallel_tool_config(&self, model: &str) -> bool {
172        self.inner.supports_parallel_tool_config(model)
173    }
174
175    fn supports_structured_output(&self, model: &str) -> bool {
176        self.inner.supports_structured_output(model)
177    }
178
179    fn supports_context_caching(&self, model: &str) -> bool {
180        self.inner.supports_context_caching(model)
181    }
182
183    fn supports_vision(&self, model: &str) -> bool {
184        self.inner.supports_vision(model)
185    }
186
187    fn supports_responses_compaction(&self, model: &str) -> bool {
188        self.inner.supports_responses_compaction(model)
189    }
190
191    fn effective_context_size(&self, model: &str) -> usize {
192        self.inner.effective_context_size(model)
193    }
194
195    async fn compact_history(
196        &self,
197        model: &str,
198        history: &[Message],
199    ) -> Result<Vec<Message>, LLMError> {
200        self.inner.compact_history(model, history).await
201    }
202
203    async fn generate(&self, mut request: LLMRequest) -> Result<LLMResponse, LLMError> {
204        ensure_model(&mut request, &self.model);
205        self.inner.generate(request).await
206    }
207
208    async fn stream(&self, mut request: LLMRequest) -> Result<LLMStream, LLMError> {
209        ensure_model(&mut request, &self.model);
210        self.inner.stream(request).await
211    }
212
213    fn supported_models(&self) -> Vec<String> {
214        models::minimax::SUPPORTED_MODELS
215            .iter()
216            .map(|s| s.to_string())
217            .collect()
218    }
219
220    fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
221        self.inner.validate_request(request)
222    }
223}
224
225impl_llm_client!(MinimaxProvider);
226
227#[cfg(test)]
228mod tests {
229    use super::{MinimaxProvider, resolve_minimax_base_url};
230    use crate::config::constants::models;
231    use crate::llm::client::LLMClient;
232    use crate::llm::provider::LLMProvider;
233
234    #[test]
235    fn resolve_minimax_base_url_defaults_to_anthropic_v1() {
236        assert_eq!(
237            resolve_minimax_base_url(None),
238            "https://api.minimax.io/anthropic/v1"
239        );
240    }
241
242    #[test]
243    fn resolve_minimax_base_url_normalizes_root_host_to_anthropic_v1() {
244        assert_eq!(
245            resolve_minimax_base_url(Some("https://api.minimax.io".to_string())),
246            "https://api.minimax.io/anthropic/v1"
247        );
248        assert_eq!(
249            resolve_minimax_base_url(Some("https://api.minimax.io/v1".to_string())),
250            "https://api.minimax.io/anthropic/v1"
251        );
252    }
253
254    #[test]
255    fn resolve_minimax_base_url_keeps_explicit_anthropic_path() {
256        assert_eq!(
257            resolve_minimax_base_url(Some("https://api.minimax.io/anthropic".to_string())),
258            "https://api.minimax.io/anthropic/v1"
259        );
260        assert_eq!(
261            resolve_minimax_base_url(Some(
262                "https://api.minimax.io/anthropic/v1/messages".to_string()
263            )),
264            "https://api.minimax.io/anthropic/v1"
265        );
266    }
267
268    #[test]
269    fn resolve_minimax_base_url_respects_custom_proxy_path() {
270        assert_eq!(
271            resolve_minimax_base_url(Some("https://proxy.example.com/minimax".to_string())),
272            "https://proxy.example.com/minimax/v1"
273        );
274    }
275
276    #[test]
277    fn minimax_provider_preserves_provider_name_and_default_model() {
278        let provider = MinimaxProvider::from_config(
279            Some("test-key".to_string()),
280            None,
281            None,
282            None,
283            None,
284            None,
285            None,
286        );
287
288        assert_eq!(provider.name(), "minimax");
289        assert_eq!(provider.model_id(), models::minimax::DEFAULT_MODEL);
290        assert_eq!(
291            provider.supported_models(),
292            vec![
293                models::minimax::MINIMAX_M3.to_string(),
294                models::minimax::MINIMAX_M2_7.to_string(),
295                models::minimax::MINIMAX_M2_5.to_string(),
296            ]
297        );
298    }
299
300    #[test]
301    fn minimax_provider_blank_model_falls_back_to_default() {
302        let provider = MinimaxProvider::from_config(
303            Some("test-key".to_string()),
304            Some("   ".to_string()),
305            None,
306            None,
307            None,
308            None,
309            None,
310        );
311
312        assert_eq!(provider.model_id(), models::minimax::DEFAULT_MODEL);
313    }
314
315    #[test]
316    fn minimax_provider_supports_streaming() {
317        let provider = MinimaxProvider::from_config(
318            Some("test-key".to_string()),
319            None,
320            None,
321            None,
322            None,
323            None,
324            None,
325        );
326
327        assert!(provider.supports_streaming());
328    }
329}