vtcode_llm/providers/
mistral.rs1use 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 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 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 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 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}