Skip to main content

rig_core/providers/mistral/
completion.rs

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
12/// The latest version of the `codestral` Mistral model
13pub const CODESTRAL: &str = "codestral-latest";
14/// The latest version of the `mistral-large` Mistral model
15pub const MISTRAL_LARGE: &str = "mistral-large-latest";
16/// The latest version of the `pixtral-large` Mistral multimodal model
17pub const PIXTRAL_LARGE: &str = "pixtral-large-latest";
18/// The latest version of the `mistral` Mistral multimodal model, trained on datasets from the Middle East & South Asia
19pub const MISTRAL_SABA: &str = "mistral-saba-latest";
20/// The latest version of the `mistral-3b` Mistral completions model
21pub const MINISTRAL_3B: &str = "ministral-3b-latest";
22/// The latest version of the `mistral-8b` Mistral completions model
23pub const MINISTRAL_8B: &str = "ministral-8b-latest";
24
25/// The latest version of the `mistral-small` Mistral completions model
26pub const MISTRAL_SMALL: &str = "mistral-small-latest";
27/// The `24-09` version of the `pixtral-small` Mistral multimodal model
28pub const PIXTRAL_SMALL: &str = "pixtral-12b-2409";
29/// The `open-mistral-nemo` model
30pub const MISTRAL_NEMO: &str = "open-mistral-nemo";
31/// The `open-mistral-mamba` model
32pub const CODESTRAL_MAMBA: &str = "open-codestral-mamba";
33
34/// Mistral completion model, driven by the shared OpenAI Chat Completions path.
35pub type CompletionModel<H = reqwest::Client> =
36    openai::completion::GenericCompletionModel<MistralExt, H>;
37
38/// Final streaming response, shared with the OpenAI Chat Completions path but
39/// carrying Mistral's own usage payload (cached-token fallbacks).
40pub type MistralStreamingCompletionResponse = openai::StreamingCompletionResponse<Usage>;
41
42// =================================================================
43// Rig Implementation Types
44// =================================================================
45
46fn 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/// Mistral's provider-native message shape, as it appears in responses.
72#[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        /// The name of the tool that was called
95        #[serde(skip_serializing_if = "String::is_empty")]
96        name: String,
97        /// The content of the tool call
98        content: String,
99        /// The id of the tool call
100        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        // The singular alias form used by some Mistral responses.
349        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}