Skip to main content

vtcode_llm/providers/
minimax.rs

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