Skip to main content

vtcode_llm/providers/
mistral.rs

1use serde_json::{Map, Value};
2use vtcode_config::constants::{env_vars, models, urls};
3
4use super::openai_compat::{OpenAiCompatCore, OpenAiCompatSpec, impl_openai_compat_provider};
5use crate::provider::{LLMError, LLMRequest};
6
7pub struct MistralSpec;
8
9impl OpenAiCompatSpec for MistralSpec {
10    const NAME: &'static str = "Mistral";
11    const KEY: &'static str = "mistral";
12    const API_KEY_ENV: &'static str = "MISTRAL_API_KEY";
13    const DEFAULT_MODEL: &'static str = models::mistral::DEFAULT_MODEL;
14    const DEFAULT_BASE_URL: &'static str = urls::MISTRAL_API_BASE;
15    const BASE_URL_ENV: Option<&'static str> = Some(env_vars::MISTRAL_BASE_URL);
16    const LISTED_MODELS: &'static [&'static str] = models::mistral::SUPPORTED_MODELS;
17    const VALIDATION_ALLOWLIST: Option<&'static [&'static str]> = Some(models::mistral::SUPPORTED_MODELS);
18
19    const SUPPRESS_SAMPLING_WHEN_REASONING: bool = false;
20    const STREAM_OPTIONS_INCLUDE_USAGE: bool = true;
21    const INCLUDE_USER_ID: bool = true;
22    const DELTA_ORDER: super::shared::OpenAiDeltaOrder = super::shared::OpenAiDeltaOrder::ContentFirst;
23
24    fn response_cache_metrics(core: &OpenAiCompatCore<Self>) -> bool {
25        core.prompt_cache_enabled
26    }
27
28    fn stream_cache_metrics(_core: &OpenAiCompatCore<Self>) -> bool {
29        true
30    }
31
32    fn insert_tool_choice(_core: &OpenAiCompatCore<Self>, request: &LLMRequest, payload: &mut Map<String, Value>) {
33        if let Some(choice) = &request.tool_choice {
34            payload.insert("tool_choice".to_owned(), choice.to_provider_format(Self::KEY));
35        } else if request.tools.as_ref().is_some_and(|t| !t.is_empty()) {
36            // Mistral's default "auto" tool_choice sometimes causes the model
37            // to emit tool call arguments as plain text content. Setting it
38            // explicitly when tools are present helps the model use
39            // structured tool_calls.
40            payload.insert("tool_choice".to_owned(), Value::String("auto".to_owned()));
41        }
42    }
43
44    fn insert_reasoning(
45        _core: &OpenAiCompatCore<Self>,
46        request: &LLMRequest,
47        payload: &mut Map<String, Value>,
48    ) -> Result<(), LLMError> {
49        if let Some(effort) = request.reasoning_effort
50            && effort != vtcode_config::types::ReasoningEffortLevel::None
51        {
52            payload.insert("reasoning_effort".to_owned(), Value::String("high".to_owned()));
53        }
54        Ok(())
55    }
56
57    fn finish_payload(
58        _core: &OpenAiCompatCore<Self>,
59        _request: &LLMRequest,
60        payload: &mut Map<String, Value>,
61    ) -> Result<(), LLMError> {
62        if payload.contains_key("tools") {
63            payload.insert("parallel_tool_calls".to_owned(), Value::Bool(false));
64        }
65        Ok(())
66    }
67}
68
69impl_openai_compat_provider!(MistralProvider, MistralSpec, {
70    fn supports_streaming(&self) -> bool {
71        true
72    }
73
74    fn supports_structured_output(&self, _model: &str) -> bool {
75        true
76    }
77
78    fn supports_vision(&self, _model: &str) -> bool {
79        true
80    }
81
82    fn supports_reasoning(&self, model: &str) -> bool {
83        let requested = if model.trim().is_empty() {
84            &self.core.model
85        } else {
86            model
87        };
88
89        self.core
90            .model_behavior
91            .as_ref()
92            .and_then(|b| b.model_supports_reasoning)
93            .unwrap_or(false)
94            || requested == models::mistral::MISTRAL_LARGE_3
95            || requested == models::mistral::MISTRAL_LARGE_4
96    }
97
98    fn supports_reasoning_effort(&self, _model: &str) -> bool {
99        self.core
100            .model_behavior
101            .as_ref()
102            .and_then(|b| b.model_supports_reasoning_effort)
103            .unwrap_or(false)
104    }
105
106    fn effective_context_size(&self, model: &str) -> usize {
107        let requested = if model.trim().is_empty() {
108            &self.core.model
109        } else {
110            model
111        };
112        match requested {
113            models::mistral::MISTRAL_LARGE_4 => 1_000_000,
114            _ => 256_000,
115        }
116    }
117});
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::provider::{LLMProvider, Message, ToolChoice, ToolDefinition};
123    use std::sync::Arc;
124    use vtcode_config::types::ReasoningEffortLevel;
125
126    fn provider() -> MistralProvider {
127        MistralProvider::from_config(
128            Some("test-key".to_string()),
129            Some("mistral-large-latest".to_string()),
130            Some("https://example.test/v1".to_string()),
131            None,
132            None,
133            None,
134            None,
135        )
136    }
137
138    fn base_request() -> LLMRequest {
139        LLMRequest {
140            messages: vec![Message::user("hello".to_string())].into(),
141            system_prompt: Some(Arc::from("system guidance")),
142            model: "mistral-large-latest".to_string(),
143            max_tokens: Some(512),
144            temperature: Some(0.5),
145            top_p: Some(0.25),
146            stream: true,
147            metadata: Some(serde_json::json!({"user_id": "user-42"})),
148            ..Default::default()
149        }
150    }
151
152    fn sample_tools() -> Arc<Vec<ToolDefinition>> {
153        Arc::new(vec![ToolDefinition::function(
154            "lookup".to_string(),
155            "Look things up".to_string(),
156            serde_json::json!({"type": "object", "properties": {}}),
157        )])
158    }
159
160    #[test]
161    fn golden_payload_basic_shape() {
162        let payload = provider().core.convert_request(&base_request()).unwrap();
163
164        assert_eq!(payload["model"], "mistral-large-latest");
165        let messages = payload["messages"].as_array().unwrap();
166        assert_eq!(messages.len(), 2);
167        assert_eq!(messages[0]["role"], "system");
168        assert_eq!(messages[0]["content"], "system guidance");
169        assert_eq!(payload["max_tokens"], 512);
170        assert_eq!(payload["temperature"], 0.5);
171        assert_eq!(payload["top_p"], 0.25);
172        assert_eq!(payload["stream"], true);
173        assert_eq!(payload["stream_options"]["include_usage"], true);
174        assert_eq!(payload["user_id"], "user-42");
175        assert!(payload.get("tools").is_none());
176        assert!(payload.get("tool_choice").is_none());
177        assert!(payload.get("parallel_tool_calls").is_none());
178        assert!(payload.get("reasoning_effort").is_none());
179    }
180
181    #[test]
182    fn golden_payload_tools_disable_parallel_calls_and_default_to_auto() {
183        let mut request = base_request();
184        request.tools = Some(sample_tools());
185        let payload = provider().core.convert_request(&request).unwrap();
186        assert_eq!(payload["tools"].as_array().unwrap().len(), 1);
187        assert_eq!(payload["parallel_tool_calls"], false);
188        // Implicit tool_choice defaults to auto when tools are present.
189        assert_eq!(payload["tool_choice"], "auto");
190
191        let mut request = base_request();
192        request.tools = Some(sample_tools());
193        request.tool_choice = Some(ToolChoice::Any);
194        let payload = provider().core.convert_request(&request).unwrap();
195        assert_eq!(payload["tool_choice"], ToolChoice::Any.to_provider_format("mistral"));
196    }
197
198    #[test]
199    fn large_4_reports_1m_context_and_reasoning() {
200        let large_4 = MistralProvider::from_config(
201            Some("test-key".to_string()),
202            Some(models::mistral::MISTRAL_LARGE_4.to_string()),
203            Some("https://example.test/v1".to_string()),
204            None,
205            None,
206            None,
207            None,
208        );
209        assert_eq!(large_4.effective_context_size(models::mistral::MISTRAL_LARGE_4), 1_000_000);
210        assert!(large_4.supports_reasoning(models::mistral::MISTRAL_LARGE_4));
211        // Asymmetric boundaries: Large 3 keeps 256k; empty falls back to the
212        // configured model (Large 4 here, Large 3 below).
213        assert_eq!(large_4.effective_context_size(models::mistral::MISTRAL_LARGE_3), 256_000);
214        assert_eq!(large_4.effective_context_size(""), 1_000_000);
215        assert_eq!(provider().effective_context_size(""), 256_000);
216    }
217
218    #[test]
219    fn golden_payload_reasoning_effort_pinned_to_high() {
220        let mut request = base_request();
221        request.reasoning_effort = Some(ReasoningEffortLevel::Low);
222        let payload = provider().core.convert_request(&request).unwrap();
223        assert_eq!(payload["reasoning_effort"], "high");
224        // Sampling parameters are not suppressed.
225        assert_eq!(payload["temperature"], 0.5);
226
227        let mut request = base_request();
228        request.reasoning_effort = Some(ReasoningEffortLevel::None);
229        let payload = provider().core.convert_request(&request).unwrap();
230        assert!(payload.get("reasoning_effort").is_none());
231    }
232}