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 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}