1use serde::{Deserialize, Deserializer, Serialize};
2
3use super::client::{MistralExt, Usage};
4use crate::completion::GetTokenUsage;
5use crate::providers::openai;
6use crate::{
7 OneOrMany,
8 completion::{self, CompletionError},
9 json_utils,
10};
11
12pub const CODESTRAL: &str = "codestral-latest";
14pub const MISTRAL_LARGE: &str = "mistral-large-latest";
16pub const PIXTRAL_LARGE: &str = "pixtral-large-latest";
18pub const MISTRAL_SABA: &str = "mistral-saba-latest";
20pub const MINISTRAL_3B: &str = "ministral-3b-latest";
22pub const MINISTRAL_8B: &str = "ministral-8b-latest";
24
25pub const MISTRAL_SMALL: &str = "mistral-small-latest";
27pub const PIXTRAL_SMALL: &str = "pixtral-12b-2409";
29pub const MISTRAL_NEMO: &str = "open-mistral-nemo";
31pub const CODESTRAL_MAMBA: &str = "open-codestral-mamba";
33
34pub type CompletionModel<H = reqwest::Client> =
36 openai::completion::GenericCompletionModel<MistralExt, H>;
37
38pub type MistralStreamingCompletionResponse = openai::StreamingCompletionResponse<Usage>;
41
42fn mistral_content_value_to_text(value: serde_json::Value) -> String {
47 match value {
48 serde_json::Value::String(text) => text,
49 serde_json::Value::Array(parts) => openai::completion::joined_text_parts(&parts),
50 _ => String::new(),
51 }
52}
53
54fn deserialize_mistral_content_string<'de, D>(deserializer: D) -> Result<String, D::Error>
55where
56 D: Deserializer<'de>,
57{
58 Ok(Option::<serde_json::Value>::deserialize(deserializer)?
59 .map(mistral_content_value_to_text)
60 .unwrap_or_default())
61}
62
63#[derive(Debug, Serialize, Deserialize, Clone)]
64pub struct Choice {
65 pub index: usize,
66 pub message: Message,
67 pub logprobs: Option<serde_json::Value>,
68 pub finish_reason: String,
69}
70
71#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
73#[serde(tag = "role", rename_all = "lowercase")]
74pub enum Message {
75 User {
76 content: String,
77 },
78 Assistant {
79 #[serde(default, deserialize_with = "deserialize_mistral_content_string")]
80 content: String,
81 #[serde(
82 default,
83 deserialize_with = "json_utils::null_or_vec",
84 skip_serializing_if = "Vec::is_empty"
85 )]
86 tool_calls: Vec<ToolCall>,
87 #[serde(default)]
88 prefix: bool,
89 },
90 System {
91 content: String,
92 },
93 Tool {
94 #[serde(skip_serializing_if = "String::is_empty")]
96 name: String,
97 content: String,
99 tool_call_id: String,
101 },
102}
103
104#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
105pub struct ToolCall {
106 pub id: String,
107 #[serde(default)]
108 pub r#type: ToolType,
109 pub function: Function,
110}
111
112#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
113pub struct Function {
114 pub name: String,
115 #[serde(with = "json_utils::stringified_json")]
116 pub arguments: serde_json::Value,
117}
118
119#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)]
120#[serde(rename_all = "lowercase")]
121pub enum ToolType {
122 #[default]
123 Function,
124}
125
126#[derive(Debug, Deserialize, Clone, Serialize)]
127pub struct CompletionResponse {
128 pub id: String,
129 pub object: String,
130 pub created: u64,
131 pub model: String,
132 pub system_fingerprint: Option<String>,
133 pub choices: Vec<Choice>,
134 pub usage: Option<Usage>,
135}
136
137impl crate::telemetry::ProviderResponseExt for CompletionResponse {
138 type OutputMessage = Choice;
139 type Usage = Usage;
140
141 fn get_response_id(&self) -> Option<String> {
142 Some(self.id.clone())
143 }
144
145 fn get_response_model_name(&self) -> Option<String> {
146 Some(self.model.clone())
147 }
148
149 fn get_output_messages(&self) -> Vec<Self::OutputMessage> {
150 self.choices.clone()
151 }
152
153 fn get_text_response(&self) -> Option<String> {
154 let res = self
155 .choices
156 .iter()
157 .filter_map(|choice| match choice.message {
158 Message::Assistant { ref content, .. } => {
159 if content.is_empty() {
160 None
161 } else {
162 Some(content.to_string())
163 }
164 }
165 _ => None,
166 })
167 .collect::<Vec<String>>()
168 .join("\n");
169
170 if res.is_empty() { None } else { Some(res) }
171 }
172
173 fn get_usage(&self) -> Option<Self::Usage> {
174 self.usage.clone()
175 }
176}
177
178impl GetTokenUsage for Usage {
179 fn token_usage(&self) -> crate::completion::Usage {
180 let mut usage = crate::completion::Usage::new();
181 usage.input_tokens = self.prompt_tokens as u64;
182 usage.output_tokens = self.completion_tokens as u64;
183 usage.total_tokens = self.total_tokens as u64;
184 usage.cached_input_tokens = self.cached_tokens();
185 usage
186 }
187}
188
189impl GetTokenUsage for CompletionResponse {
190 fn token_usage(&self) -> crate::completion::Usage {
191 self.usage
192 .as_ref()
193 .map(GetTokenUsage::token_usage)
194 .unwrap_or_default()
195 }
196}
197
198impl TryFrom<CompletionResponse> for completion::CompletionResponse<CompletionResponse> {
199 type Error = CompletionError;
200
201 fn try_from(response: CompletionResponse) -> Result<Self, Self::Error> {
202 let choice = response.choices.first().ok_or_else(|| {
203 CompletionError::ResponseError("Response contained no choices".to_owned())
204 })?;
205 let content = match &choice.message {
206 Message::Assistant {
207 content,
208 tool_calls,
209 ..
210 } => {
211 let mut content = if content.is_empty() {
212 vec![]
213 } else {
214 vec![completion::AssistantContent::text(content.clone())]
215 };
216
217 content.extend(
218 tool_calls
219 .iter()
220 .map(|call| {
221 completion::AssistantContent::tool_call(
222 &call.id,
223 &call.function.name,
224 call.function.arguments.clone(),
225 )
226 })
227 .collect::<Vec<_>>(),
228 );
229 Ok(content)
230 }
231 _ => Err(CompletionError::ResponseError(
232 "Response did not contain a valid message or tool call".into(),
233 )),
234 }?;
235
236 let choice = OneOrMany::many(content).map_err(|_| {
237 CompletionError::ResponseError(
238 "Response contained no message or tool call (empty)".to_owned(),
239 )
240 })?;
241
242 let usage = response
243 .usage
244 .as_ref()
245 .map(|usage| completion::Usage {
246 input_tokens: usage.prompt_tokens as u64,
247 output_tokens: usage.completion_tokens as u64,
248 total_tokens: usage.total_tokens as u64,
249 cached_input_tokens: usage.cached_tokens(),
250 cache_creation_input_tokens: 0,
251 tool_use_prompt_tokens: 0,
252 reasoning_tokens: 0,
253 })
254 .unwrap_or_default();
255
256 let message_id = response.id.clone();
257
258 Ok(completion::CompletionResponse {
259 choice,
260 usage,
261 raw_response: response,
262 message_id: Some(message_id),
263 })
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::providers::openai::completion::OpenAICompatibleProvider;
271
272 #[test]
273 fn deserializes_response_with_array_and_null_content() {
274 let data = r#"{
275 "id": "cmpl-1",
276 "object": "chat.completion",
277 "created": 1,
278 "model": "mistral-small-latest",
279 "system_fingerprint": null,
280 "choices": [
281 {
282 "index": 0,
283 "message": {
284 "role": "assistant",
285 "content": [{"type": "text", "text": "Hello"}, {"type": "text", "text": " world"}]
286 },
287 "logprobs": null,
288 "finish_reason": "stop"
289 },
290 {
291 "index": 1,
292 "message": {
293 "role": "assistant",
294 "content": null,
295 "tool_calls": [{
296 "id": "call_1",
297 "type": "function",
298 "function": {"name": "add", "arguments": "{\"x\":1,\"y\":2}"}
299 }]
300 },
301 "logprobs": null,
302 "finish_reason": "tool_calls"
303 }
304 ],
305 "usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
306 }"#;
307
308 let response: CompletionResponse =
309 serde_json::from_str(data).expect("response should deserialize");
310 match &response.choices[0].message {
311 Message::Assistant { content, .. } => assert_eq!(content, "Hello world"),
312 _ => panic!("expected assistant message"),
313 }
314 match &response.choices[1].message {
315 Message::Assistant {
316 content,
317 tool_calls,
318 ..
319 } => {
320 assert_eq!(content, "");
321 assert_eq!(tool_calls[0].function.name, "add");
322 }
323 _ => panic!("expected assistant message"),
324 }
325 }
326
327 #[test]
328 fn usage_prefers_structured_cached_tokens_and_falls_back() {
329 let structured: Usage = serde_json::from_value(serde_json::json!({
330 "prompt_tokens": 10,
331 "completion_tokens": 5,
332 "total_tokens": 15,
333 "num_cached_tokens": 2,
334 "prompt_tokens_details": {"cached_tokens": 7}
335 }))
336 .expect("usage should deserialize");
337 assert_eq!(structured.cached_tokens(), 7);
338
339 let fallback: Usage = serde_json::from_value(serde_json::json!({
340 "prompt_tokens": 10,
341 "completion_tokens": 5,
342 "total_tokens": 15,
343 "num_cached_tokens": 2
344 }))
345 .expect("usage should deserialize");
346 assert_eq!(fallback.cached_tokens(), 2);
347
348 let aliased: Usage = serde_json::from_value(serde_json::json!({
350 "prompt_tokens": 10,
351 "completion_tokens": 5,
352 "total_tokens": 15,
353 "prompt_token_details": {"cached_tokens": 4}
354 }))
355 .expect("usage should deserialize");
356 assert_eq!(aliased.cached_tokens(), 4);
357 }
358
359 #[test]
360 fn finalize_rewrites_required_tool_choice_to_any() {
361 let mut body = serde_json::json!({
362 "model": "mistral-small-latest",
363 "messages": [{"role": "user", "content": "hi"}],
364 "tool_choice": "required"
365 });
366
367 MistralExt
368 .finalize_request_body(&mut body)
369 .expect("finalize should succeed");
370
371 assert_eq!(body["tool_choice"], "any");
372 }
373
374 #[test]
375 fn finalize_preserves_specific_function_tool_choice() {
376 let mut body = serde_json::json!({
377 "model": "mistral-small-latest",
378 "messages": [{"role": "user", "content": "hi"}],
379 "tool_choice": {"type": "function", "function": {"name": "beta"}}
380 });
381
382 MistralExt
383 .finalize_request_body(&mut body)
384 .expect("finalize should succeed");
385
386 assert_eq!(
387 body["tool_choice"],
388 serde_json::json!({"type": "function", "function": {"name": "beta"}})
389 );
390 }
391
392 #[test]
393 fn finalize_flattens_assistant_history_and_adds_prefix() {
394 let mut body = serde_json::json!({
395 "model": "mistral-small-latest",
396 "messages": [
397 {"role": "system", "content": [{"type": "text", "text": "Be brief."}]},
398 {"role": "user", "content": "hi"},
399 {
400 "role": "assistant",
401 "content": [{"type": "text", "text": "Hello."}],
402 "reasoning_content": "hidden thoughts"
403 },
404 {
405 "role": "assistant",
406 "tool_calls": [{
407 "id": "call_1",
408 "type": "function",
409 "function": {"name": "add", "arguments": "{}"}
410 }]
411 }
412 ]
413 });
414
415 MistralExt
416 .finalize_request_body(&mut body)
417 .expect("finalize should succeed");
418
419 assert_eq!(body["messages"][0]["content"], "Be brief.");
420 assert_eq!(body["messages"][2]["content"], "Hello.");
421 assert_eq!(body["messages"][2]["prefix"], false);
422 assert!(
423 body["messages"][2].get("reasoning_content").is_none(),
424 "Mistral rejects unknown assistant fields; reasoning must be stripped"
425 );
426 assert_eq!(body["messages"][3]["content"], "");
427 assert_eq!(body["messages"][3]["prefix"], false);
428 }
429}