Skip to main content

rig_core/providers/mistral/
completion.rs

1use serde::{Deserialize, Deserializer, Serialize};
2
3use super::client::{MistralExt, Usage};
4use crate::providers::openai;
5use crate::{
6    completion::{self, CompletionError},
7    json_utils,
8};
9
10/// The latest version of the `codestral` Mistral model
11pub const CODESTRAL: &str = "codestral-latest";
12/// The latest version of the `mistral-large` Mistral model
13pub const MISTRAL_LARGE: &str = "mistral-large-latest";
14/// The latest version of the `pixtral-large` Mistral multimodal model
15///
16/// **Retired.** This identifier is no longer in Mistral's `GET /v1/models`
17/// catalog; requests naming it fail with `400 Invalid model`.
18#[deprecated(
19    note = "Mistral no longer serves this model. Pixtral is retired; use `MISTRAL_SMALL` or `MISTRAL_MEDIUM`, which are vision-capable"
20)]
21pub const PIXTRAL_LARGE: &str = "pixtral-large-latest";
22/// The latest version of the `mistral` Mistral multimodal model, trained on datasets from the Middle East & South Asia
23///
24/// **Retired.** This identifier is no longer in Mistral's `GET /v1/models`
25/// catalog; requests naming it fail with `400 Invalid model`.
26#[deprecated(
27    note = "Mistral no longer serves this model. retired; no replacement in the live catalog"
28)]
29pub const MISTRAL_SABA: &str = "mistral-saba-latest";
30/// The latest version of the `mistral-3b` Mistral completions model
31pub const MINISTRAL_3B: &str = "ministral-3b-latest";
32/// The latest version of the `mistral-8b` Mistral completions model
33pub const MINISTRAL_8B: &str = "ministral-8b-latest";
34
35/// The latest version of the `mistral-small` Mistral completions model
36pub const MISTRAL_SMALL: &str = "mistral-small-latest";
37/// The `24-09` version of the `pixtral-small` Mistral multimodal model
38///
39/// **Retired.** This identifier is no longer in Mistral's `GET /v1/models`
40/// catalog; requests naming it fail with `400 Invalid model`.
41#[deprecated(
42    note = "Mistral no longer serves this model. Pixtral is retired; use `MINISTRAL_3B`, which is vision-capable"
43)]
44pub const PIXTRAL_SMALL: &str = "pixtral-12b-2409";
45/// The `open-mistral-nemo` model
46///
47/// **Retired.** This identifier is no longer in Mistral's `GET /v1/models`
48/// catalog; requests naming it fail with `400 Invalid model`.
49#[deprecated(
50    note = "Mistral no longer serves this model. retired; no replacement in the live catalog"
51)]
52pub const MISTRAL_NEMO: &str = "open-mistral-nemo";
53/// The `open-mistral-mamba` model
54///
55/// **Retired.** This identifier is no longer in Mistral's `GET /v1/models`
56/// catalog; requests naming it fail with `400 Invalid model`.
57#[deprecated(note = "Mistral no longer serves this model. retired; use `CODESTRAL`")]
58pub const CODESTRAL_MAMBA: &str = "open-codestral-mamba";
59
60/// Mistral completion model, driven by the shared OpenAI Chat Completions path.
61pub type CompletionModel<H = reqwest::Client> =
62    openai::completion::GenericCompletionModel<MistralExt, H>;
63
64/// Mistral's provider-native terminal streaming record: the value carried by
65/// the final item of the stream returned by `CompletionModel::raw_stream`.
66/// Shared with the OpenAI Chat Completions path but carrying Mistral's own
67/// usage payload (cached-token fallbacks).
68pub type MistralStreamingCompletionResponse =
69    openai::StreamingCompletionResponse<super::client::Usage>;
70
71// =================================================================
72// Rig Implementation Types
73// =================================================================
74
75fn mistral_content_value_to_text(value: serde_json::Value) -> String {
76    match value {
77        serde_json::Value::String(text) => text,
78        serde_json::Value::Array(parts) => openai::completion::joined_text_parts(&parts),
79        _ => String::new(),
80    }
81}
82
83fn deserialize_mistral_content_string<'de, D>(deserializer: D) -> Result<String, D::Error>
84where
85    D: Deserializer<'de>,
86{
87    Ok(Option::<serde_json::Value>::deserialize(deserializer)?
88        .map(mistral_content_value_to_text)
89        .unwrap_or_default())
90}
91
92/// Mistral's content-chunk tags. The API validates message content as a
93/// tagged union over `text`, `image_url`, `document_url`, `reference`, `bbox`,
94/// `file_url`, `input_audio`, `file`, `thinking`, `resource` and
95/// `resource_link`; the shared OpenAI-compatible message conversion can
96/// produce content for the five named here.
97const TEXT_CHUNK: &str = "text";
98const IMAGE_CHUNK: &str = "image_url";
99const AUDIO_CHUNK: &str = "input_audio";
100const DOCUMENT_CHUNK: &str = "document_url";
101const FILE_CHUNK: &str = "file";
102/// OpenAI's refusal part. Textual content, but under a key Mistral's chunk
103/// schema has no field for, so it is re-tagged rather than forwarded.
104const REFUSAL_TYPE: &str = "refusal";
105
106/// The text a part carries, under either of the two keys the shared
107/// OpenAI-compatible conversion can put it under.
108fn part_text(part: &serde_json::Value) -> Option<&str> {
109    part.get(TEXT_CHUNK)
110        .and_then(serde_json::Value::as_str)
111        .or_else(|| part.get(REFUSAL_TYPE).and_then(serde_json::Value::as_str))
112}
113
114/// Whether a serialized content part is purely textual, and so belongs in the
115/// plain-string form rather than a chunk array.
116///
117/// Decided on the `type` tag first, and only on the keys for a part that
118/// carries no tag. Deciding on the keys alone — as the text-only flattening
119/// this replaces does — would let a part that names a chunk kind *and* happens
120/// to carry a `text` key be flattened away, which is the same silent drop
121/// this whole path exists to prevent.
122fn is_text_part(part: &serde_json::Value) -> bool {
123    match part.get("type").and_then(serde_json::Value::as_str) {
124        Some(TEXT_CHUNK | REFUSAL_TYPE) => true,
125        Some(_) => false,
126        None => part_text(part).is_some(),
127    }
128}
129
130fn unsupported_content_error(what: &str) -> CompletionError {
131    crate::message::MessageError::ConversionError(format!(
132        "Mistral cannot carry {what}. Mistral messages accept text, `{IMAGE_CHUNK}`, \
133         `{AUDIO_CHUNK}`, `{DOCUMENT_CHUNK}` and `{FILE_CHUNK}` content; convert the content \
134         to one of those before sending it."
135    ))
136    .into()
137}
138
139/// Convert OpenAI's `{"type": "file", "file": {…}}` part into the Mistral
140/// chunk carrying the same document.
141///
142/// Inline bytes become `document_url`, which reads the base64 `data:` URI the
143/// shared conversion already built for `file_data`, and carries the filename
144/// in its own optional `document_name` field. An uploaded-file reference
145/// becomes Mistral's `file` chunk, which names the id at the top level rather
146/// than nesting it under `file` as OpenAI does — sending OpenAI's nesting is
147/// rejected twice over, for a missing `file_id` and for a forbidden extra
148/// `file`, since every Mistral chunk forbids unknown fields.
149fn file_part_to_mistral_chunk(
150    part: &serde_json::Value,
151) -> Result<serde_json::Value, CompletionError> {
152    let file = part.get(FILE_CHUNK);
153    let field = |name: &str| {
154        file.and_then(|file| file.get(name))
155            .and_then(serde_json::Value::as_str)
156    };
157
158    // Already a Mistral file chunk (`file_id` at the top level, as this
159    // function emits): pass it through so finalizing an already-finalized body
160    // is a no-op rather than an error about content rig itself built.
161    if let Some(file_id) = part.get("file_id").and_then(serde_json::Value::as_str) {
162        return Ok(serde_json::json!({"type": FILE_CHUNK, "file_id": file_id}));
163    }
164
165    if let Some(data) = field("file_data") {
166        // `document_name` is Mistral's own optional filename field; it is left
167        // out entirely rather than sent as null when the part has no filename.
168        Ok(match field("filename") {
169            Some(filename) => serde_json::json!({
170                "type": DOCUMENT_CHUNK,
171                DOCUMENT_CHUNK: data,
172                "document_name": filename,
173            }),
174            None => serde_json::json!({"type": DOCUMENT_CHUNK, DOCUMENT_CHUNK: data}),
175        })
176    } else if let Some(file_id) = field("file_id") {
177        Ok(serde_json::json!({"type": FILE_CHUNK, "file_id": file_id}))
178    } else {
179        Err(unsupported_content_error(
180            "a file content part carrying neither `file_data` nor `file_id`",
181        ))
182    }
183}
184
185/// Rewrite an `input_audio` part into Mistral's canonical audio chunk, whose
186/// payload is the base64 string itself.
187///
188/// Mistral currently also accepts the `{data, format}` object the shared
189/// OpenAI-compatible conversion produces — its schema flattens the object and
190/// discards `format` — but the bare string is the form its published schema
191/// documents, so that is what rig sends. Nothing is lost: a deliberately wrong
192/// `format` changes no result, and a `format` placed as a *sibling* of
193/// `input_audio` is rejected outright.
194fn audio_part_to_mistral_chunk(
195    part: &serde_json::Value,
196) -> Result<serde_json::Value, CompletionError> {
197    let payload = part.get(AUDIO_CHUNK).ok_or_else(|| {
198        unsupported_content_error("an audio content part carrying no `input_audio` payload")
199    })?;
200
201    let data = match payload {
202        serde_json::Value::String(data) => data.as_str(),
203        payload => payload
204            .get("data")
205            .and_then(serde_json::Value::as_str)
206            .ok_or_else(|| {
207                unsupported_content_error(
208                    "an audio content part whose `input_audio` payload is not base64 data",
209                )
210            })?,
211    };
212
213    Ok(serde_json::json!({"type": AUDIO_CHUNK, AUDIO_CHUNK: data}))
214}
215
216/// Render one serialized content part as the Mistral chunk that carries it.
217///
218/// Dispatched on the `type` tag, which the shared OpenAI-compatible conversion
219/// always emits, so a part naming a chunk kind is converted as that kind
220/// regardless of what other keys it carries.
221fn into_mistral_chunk(part: serde_json::Value) -> Result<serde_json::Value, CompletionError> {
222    /// Text and refusal parts are both re-tagged as `text`: Mistral's chunk
223    /// schema has no `refusal` field, and every chunk forbids unknown keys.
224    fn text_chunk(part: &serde_json::Value) -> Result<serde_json::Value, CompletionError> {
225        let text = part_text(part)
226            .ok_or_else(|| unsupported_content_error("a text content part carrying no text"))?;
227        Ok(serde_json::json!({"type": TEXT_CHUNK, TEXT_CHUNK: text}))
228    }
229
230    match part.get("type").and_then(serde_json::Value::as_str) {
231        Some(TEXT_CHUNK | REFUSAL_TYPE) => text_chunk(&part),
232        // The payload needs no reshaping — Mistral's image chunk takes the
233        // `{url, detail}` object rig sends as readily as a bare URL string, and
234        // reads a base64 `data:` URI in either, with `detail` accepting exactly
235        // the `low`/`auto`/`high` range [`openai::completion::ImageDetail`]
236        // serializes. It is still rebuilt rather than forwarded, because every
237        // Mistral chunk forbids unknown fields: a stray sibling key riding on
238        // the part would 422 the whole request.
239        Some(IMAGE_CHUNK) => {
240            let image = part.get(IMAGE_CHUNK).ok_or_else(|| {
241                unsupported_content_error("an image content part carrying no `image_url` payload")
242            })?;
243            Ok(serde_json::json!({"type": IMAGE_CHUNK, IMAGE_CHUNK: image}))
244        }
245        Some(AUDIO_CHUNK) => audio_part_to_mistral_chunk(&part),
246        Some(FILE_CHUNK) => file_part_to_mistral_chunk(&part),
247        // Already a Mistral document chunk — see `file_part_to_mistral_chunk`
248        // on why an already-converted part passes through.
249        Some(DOCUMENT_CHUNK) => {
250            let url = part.get(DOCUMENT_CHUNK).ok_or_else(|| {
251                unsupported_content_error("a document content part carrying no `document_url`")
252            })?;
253            Ok(match part.get("document_name") {
254                Some(name) => serde_json::json!({
255                    "type": DOCUMENT_CHUNK, DOCUMENT_CHUNK: url, "document_name": name,
256                }),
257                None => serde_json::json!({"type": DOCUMENT_CHUNK, DOCUMENT_CHUNK: url}),
258            })
259        }
260        Some(kind) => Err(unsupported_content_error(&format!(
261            "`{kind}` message content"
262        ))),
263        // Untagged, but textual: the shared flattening would have taken it, so
264        // it converts rather than failing.
265        None if part_text(&part).is_some() => text_chunk(&part),
266        None => Err(unsupported_content_error("untyped message content")),
267    }
268}
269
270/// Rewrite one serialized message `content` value into Mistral's message
271/// content schema.
272///
273/// Mistral accepts content as either a plain string or an array of typed
274/// chunks. Text-only content keeps the plain-string form it has always taken.
275/// Content carrying anything else keeps the array, with each part rendered the
276/// way Mistral's schema names it, instead of being flattened away: the
277/// text-only flattening this replaces kept only parts with a `text`/`refusal`
278/// key, so an attached image, document or audio clip was dropped from the
279/// request and the caller got an ordinary completion answering a prompt it
280/// never sent (#2290).
281///
282/// Content Mistral has no chunk for — video, and any part type a future
283/// conversion adds — fails here rather than being silently removed. The one
284/// exception is content whose parts are *all* tagged `text`/`refusal`: that
285/// takes the flattening path, which drops a part carrying no string payload
286/// exactly as it always has, rather than inventing a new failure for a shape
287/// rig's own conversion cannot produce.
288pub(super) fn normalize_request_content(
289    content: &mut serde_json::Value,
290) -> Result<(), CompletionError> {
291    let Some(parts) = content.as_array() else {
292        return Ok(());
293    };
294
295    if parts.iter().all(is_text_part) {
296        // Flattened unconditionally rather than under `only_if_all_text`, so
297        // the helper does not re-decide: it judges per key while the guard
298        // above judges on the type tag, and the two disagree for a malformed
299        // part such as `{"type": "text"}` carrying no `text`. Letting the
300        // helper decline would leave that content as an array of chunks
301        // Mistral cannot read; flattening it reproduces what rig sent before.
302        openai::completion::flatten_text_content_parts(content, "", false);
303        return Ok(());
304    }
305
306    // Re-borrowed rather than held across the branch above, which needs
307    // `content` itself. The array-ness was just established, so the `else` is
308    // unreachable — expressed as a no-op instead of an unwrap.
309    if let Some(parts) = content.as_array_mut() {
310        for part in parts {
311            *part = into_mistral_chunk(part.take())?;
312        }
313    }
314
315    Ok(())
316}
317
318#[derive(Debug, Serialize, Deserialize, Clone)]
319pub struct Choice {
320    pub index: usize,
321    pub message: Message,
322    pub logprobs: Option<serde_json::Value>,
323    pub finish_reason: String,
324}
325
326/// Mistral's provider-native message shape, as it appears in responses.
327#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
328#[serde(tag = "role", rename_all = "lowercase")]
329pub enum Message {
330    User {
331        content: String,
332    },
333    Assistant {
334        #[serde(default, deserialize_with = "deserialize_mistral_content_string")]
335        content: String,
336        #[serde(
337            default,
338            deserialize_with = "json_utils::null_or_default",
339            skip_serializing_if = "Vec::is_empty"
340        )]
341        tool_calls: Vec<ToolCall>,
342        #[serde(default)]
343        prefix: bool,
344    },
345    System {
346        content: String,
347    },
348    Tool {
349        /// The name of the tool that was called
350        #[serde(skip_serializing_if = "String::is_empty")]
351        name: String,
352        /// The content of the tool call
353        content: String,
354        /// The id of the tool call
355        tool_call_id: String,
356    },
357}
358
359#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
360pub struct ToolCall {
361    pub id: String,
362    #[serde(default)]
363    pub r#type: ToolType,
364    pub function: Function,
365}
366
367#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
368pub struct Function {
369    pub name: String,
370    #[serde(with = "json_utils::stringified_json")]
371    pub arguments: serde_json::Value,
372}
373
374#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)]
375#[serde(rename_all = "lowercase")]
376pub enum ToolType {
377    #[default]
378    Function,
379}
380
381#[derive(Debug, Deserialize, Clone, Serialize)]
382pub struct CompletionResponse {
383    pub id: String,
384    pub object: String,
385    pub created: u64,
386    pub model: String,
387    pub system_fingerprint: Option<String>,
388    #[serde(
389        deserialize_with = "crate::providers::internal::openai_chat_completions_compatible::deserialize_choices_dropping_incomplete_tool_calls"
390    )]
391    pub choices: Vec<Choice>,
392    pub usage: Option<Usage>,
393}
394
395impl crate::telemetry::ProviderResponseExt for CompletionResponse {
396    type Usage = Usage;
397
398    fn get_response_id(&self) -> Option<String> {
399        Some(self.id.clone())
400    }
401
402    fn get_response_model_name(&self) -> Option<String> {
403        Some(self.model.clone())
404    }
405
406    fn get_text_response(&self) -> Option<String> {
407        let res = self
408            .choices
409            .iter()
410            .filter_map(|choice| match choice.message {
411                Message::Assistant { ref content, .. } => {
412                    if content.is_empty() {
413                        None
414                    } else {
415                        Some(content.to_string())
416                    }
417                }
418                _ => None,
419            })
420            .collect::<Vec<String>>()
421            .join("\n");
422
423        if res.is_empty() { None } else { Some(res) }
424    }
425
426    fn get_usage(&self) -> Option<Self::Usage> {
427        self.usage.clone()
428    }
429}
430
431/// Normalize a Mistral chat completion response.
432///
433/// The provider descriptor name is an *input* rather than a constant so the
434/// shared OpenAI-compatible completion path labels the response with the
435/// descriptor that actually produced it.
436impl crate::completion::NormalizeCompletionResponse for CompletionResponse {
437    fn normalize(self, provider: &str) -> Result<completion::CompletionResponse, CompletionError> {
438        use crate::providers::internal::openai_chat_completions_compatible as compat;
439
440        let usage = self
441            .usage
442            .as_ref()
443            .map(completion::Usage::from)
444            .unwrap_or_default();
445        compat::normalize_openai_response(
446            provider,
447            &self.choices,
448            Some(self.id.as_str()),
449            Some(self.model.as_str()),
450            usage,
451            |choice| choice.finish_reason.as_str(),
452            |choice| match &choice.message {
453                Message::Assistant {
454                    content,
455                    tool_calls,
456                    ..
457                } => Some(compat::text_then_tool_calls(
458                    content,
459                    content.is_empty(),
460                    tool_calls.iter().map(|call| {
461                        (
462                            call.id.as_str(),
463                            call.function.name.as_str(),
464                            call.function.arguments.clone(),
465                        )
466                    }),
467                )),
468                _ => None,
469            },
470        )
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use crate::completion::NormalizeCompletionResponse as _;
478    use crate::providers::openai::completion::OpenAICompatibleProvider;
479
480    #[test]
481    fn deserializes_response_with_array_and_null_content() {
482        let data = r#"{
483            "id": "cmpl-1",
484            "object": "chat.completion",
485            "created": 1,
486            "model": "mistral-small-latest",
487            "system_fingerprint": null,
488            "choices": [
489                {
490                    "index": 0,
491                    "message": {
492                        "role": "assistant",
493                        "content": [{"type": "text", "text": "Hello"}, {"type": "text", "text": " world"}]
494                    },
495                    "logprobs": null,
496                    "finish_reason": "stop"
497                },
498                {
499                    "index": 1,
500                    "message": {
501                        "role": "assistant",
502                        "content": null,
503                        "tool_calls": [{
504                            "id": "call_1",
505                            "type": "function",
506                            "function": {"name": "add", "arguments": "{\"x\":1,\"y\":2}"}
507                        }]
508                    },
509                    "logprobs": null,
510                    "finish_reason": "tool_calls"
511                }
512            ],
513            "usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
514        }"#;
515
516        let response: CompletionResponse =
517            serde_json::from_str(data).expect("response should deserialize");
518        match &response.choices[0].message {
519            Message::Assistant { content, .. } => assert_eq!(content, "Hello world"),
520            _ => panic!("expected assistant message"),
521        }
522        match &response.choices[1].message {
523            Message::Assistant {
524                content,
525                tool_calls,
526                ..
527            } => {
528                assert_eq!(content, "");
529                assert_eq!(tool_calls[0].function.name, "add");
530            }
531            _ => panic!("expected assistant message"),
532        }
533    }
534
535    #[test]
536    fn usage_prefers_structured_cached_tokens_and_falls_back() {
537        let structured: Usage = serde_json::from_value(serde_json::json!({
538            "prompt_tokens": 10,
539            "completion_tokens": 5,
540            "total_tokens": 15,
541            "num_cached_tokens": 2,
542            "prompt_tokens_details": {"cached_tokens": 7}
543        }))
544        .expect("usage should deserialize");
545        assert_eq!(structured.cached_tokens(), 7);
546
547        let fallback: Usage = serde_json::from_value(serde_json::json!({
548            "prompt_tokens": 10,
549            "completion_tokens": 5,
550            "total_tokens": 15,
551            "num_cached_tokens": 2
552        }))
553        .expect("usage should deserialize");
554        assert_eq!(fallback.cached_tokens(), 2);
555
556        // The singular alias form used by some Mistral responses.
557        let aliased: Usage = serde_json::from_value(serde_json::json!({
558            "prompt_tokens": 10,
559            "completion_tokens": 5,
560            "total_tokens": 15,
561            "prompt_token_details": {"cached_tokens": 4}
562        }))
563        .expect("usage should deserialize");
564        assert_eq!(aliased.cached_tokens(), 4);
565    }
566
567    /// Mistral reports audio outside `prompt_tokens`, so counting only that
568    /// field leaves `input + output` short of `total` by the audio payload.
569    /// The numbers are a live Voxtral turn's, quoted verbatim.
570    #[test]
571    fn usage_counts_audio_tokens_as_input() {
572        let usage: Usage = serde_json::from_value(serde_json::json!({
573            "prompt_audio_seconds": 0,
574            "prompt_tokens": 6,
575            "completion_tokens": 2,
576            "total_tokens": 383,
577            "prompt_tokens_details": {"cached_tokens": 0, "audio_tokens": 375}
578        }))
579        .expect("usage should deserialize");
580
581        assert_eq!(usage.audio_tokens(), 375);
582        assert_eq!(usage.input_tokens(), 381);
583
584        let normalized = crate::completion::Usage::from(&usage);
585        assert_eq!(normalized.input_tokens, 381);
586        assert_eq!(normalized.output_tokens, 2);
587        assert_eq!(
588            normalized.input_tokens + normalized.output_tokens,
589            normalized.total_tokens,
590            "the parts must add up to the total Mistral reported"
591        );
592    }
593
594    /// A text turn carries no audio detail, and must be unaffected.
595    #[test]
596    fn usage_without_audio_is_unchanged() {
597        let usage: Usage = serde_json::from_value(serde_json::json!({
598            "prompt_tokens": 19, "completion_tokens": 2, "total_tokens": 21,
599            "prompt_tokens_details": {"cached_tokens": 0}
600        }))
601        .expect("usage should deserialize");
602
603        assert_eq!(usage.audio_tokens(), 0);
604        assert_eq!(crate::completion::Usage::from(&usage).input_tokens, 19);
605    }
606
607    /// Mistral emits the tool call anyway when `max_tokens` runs out mid
608    /// arguments — a live turn capped at 32 tokens returned
609    /// `finish_reason: "length"` with `arguments` cut off partway through the
610    /// object. Parsing strictly took the whole response down with it.
611    #[test]
612    fn truncated_tool_arguments_do_not_destroy_the_response() {
613        let data = r#"{
614            "id": "cmpl-1", "object": "chat.completion", "created": 1,
615            "model": "mistral-small-latest", "system_fingerprint": null,
616            "choices": [{
617                "index": 0,
618                "message": {
619                    "role": "assistant",
620                    "content": "Recording that now.",
621                    "tool_calls": [{
622                        "id": "call_1", "type": "function",
623                        "function": {"name": "record", "arguments": "{\"note\": \"How to bake sour"}
624                    }]
625                },
626                "logprobs": null,
627                "finish_reason": "length"
628            }],
629            "usage": {"prompt_tokens": 30, "completion_tokens": 32, "total_tokens": 62}
630        }"#;
631
632        let response: CompletionResponse =
633            serde_json::from_str(data).expect("a truncated tool call must not fail the response");
634
635        let normalized = response
636            .normalize("mistral")
637            .expect("the turn must survive with its text and metadata");
638        assert_eq!(
639            normalized.finish_reason(),
640            Some(crate::completion::FinishReason::Length),
641            "the finish reason is what reports the truncation"
642        );
643        assert_eq!(normalized.usage.total_tokens, 62);
644        // The unusable call is dropped, as the streaming path drops it.
645        assert!(
646            normalized.choice.iter().all(|content| !matches!(
647                content,
648                crate::completion::AssistantContent::ToolCall(_)
649            )),
650            "a call with truncated arguments must not be handed to a tool"
651        );
652    }
653
654    /// A complete tool call is unaffected by the tolerant parse.
655    #[test]
656    fn complete_tool_arguments_still_parse() {
657        let data = r#"{
658            "id": "cmpl-1", "object": "chat.completion", "created": 1,
659            "model": "mistral-small-latest", "system_fingerprint": null,
660            "choices": [{
661                "index": 0,
662                "message": {"role": "assistant", "content": null, "tool_calls": [{
663                    "id": "call_1", "type": "function",
664                    "function": {"name": "add", "arguments": "{\"x\":1,\"y\":2}"}
665                }]},
666                "logprobs": null, "finish_reason": "tool_calls"
667            }],
668            "usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
669        }"#;
670
671        let normalized = serde_json::from_str::<CompletionResponse>(data)
672            .expect("response should deserialize")
673            .normalize("mistral")
674            .expect("a complete call should normalize");
675        assert!(
676            normalized
677                .choice
678                .iter()
679                .any(|content| matches!(content, crate::completion::AssistantContent::ToolCall(_))),
680            "a complete call must still reach the caller"
681        );
682    }
683
684    /// The choice-level tolerance is gated by the truncation reason. Invalid
685    /// JSON on a completed tool turn remains a response error.
686    #[test]
687    fn malformed_completed_tool_arguments_still_fail() {
688        let data = r#"{
689            "id": "cmpl-1", "object": "chat.completion", "created": 1,
690            "model": "mistral-small-latest", "system_fingerprint": null,
691            "choices": [{
692                "index": 0,
693                "message": {"role": "assistant", "content": null, "tool_calls": [{
694                    "id": "call_1", "type": "function",
695                    "function": {"name": "add", "arguments": "{\"x\":"}
696                }]},
697                "logprobs": null, "finish_reason": "tool_calls"
698            }],
699            "usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
700        }"#;
701
702        assert!(
703            serde_json::from_str::<CompletionResponse>(data).is_err(),
704            "ordinary malformed tool output must remain loud"
705        );
706    }
707
708    /// Mistral rejects a forced tool choice beside a response format with
709    /// "`json_schema` response type with tools is only compatible with
710    /// `tool_choice: auto`". Rig reaches that combination by itself on the
711    /// turn after a tool result, so finalization relaxes the choice.
712    #[test]
713    fn finalize_relaxes_a_forced_tool_choice_beside_a_response_format() {
714        let mut body = serde_json::json!({
715            "model": MISTRAL_SMALL,
716            "messages": [{"role": "user", "content": "hi"}],
717            "tool_choice": "required",
718            "tools": [{"type": "function", "function": {"name": "add", "parameters": {}}}],
719            "response_format": {"type": "json_schema", "json_schema": {"name": "Plan"}}
720        });
721        MistralExt
722            .finalize_request_body(&mut body)
723            .expect("finalize should succeed");
724        assert_eq!(body["tool_choice"], "auto");
725        assert!(
726            body.get("response_format").is_some(),
727            "the caller's schema must survive; relaxing the choice is what gives way"
728        );
729
730        // A specific function is forcing too, and equally rejected.
731        let mut body = serde_json::json!({
732            "model": MISTRAL_SMALL,
733            "messages": [{"role": "user", "content": "hi"}],
734            "tool_choice": {"type": "function", "function": {"name": "add"}},
735            "tools": [{"type": "function", "function": {"name": "add", "parameters": {}}}],
736            "response_format": {"type": "json_object"}
737        });
738        MistralExt
739            .finalize_request_body(&mut body)
740            .expect("finalize should succeed");
741        assert_eq!(body["tool_choice"], "auto");
742    }
743
744    /// The relaxation is narrow: without a response format, or without tools,
745    /// or when the choice is already compatible, nothing moves.
746    /// A `text` response format is not the constrained kind either — Mistral
747    /// takes it beside a forced choice, verified live.
748    #[test]
749    fn finalize_leaves_a_forced_tool_choice_alone_without_a_response_format() {
750        let mut body = serde_json::json!({
751            "model": MISTRAL_SMALL,
752            "messages": [{"role": "user", "content": "hi"}],
753            "tool_choice": "required",
754            "tools": [{"type": "function", "function": {"name": "add", "parameters": {}}}]
755        });
756        MistralExt
757            .finalize_request_body(&mut body)
758            .expect("finalize should succeed");
759        assert_eq!(body["tool_choice"], "any", "still just the dialect rename");
760
761        let mut body = serde_json::json!({
762            "model": MISTRAL_SMALL,
763            "messages": [{"role": "user", "content": "hi"}],
764            "tool_choice": "none",
765            "tools": [{"type": "function", "function": {"name": "add", "parameters": {}}}],
766            "response_format": {"type": "json_object"}
767        });
768        MistralExt
769            .finalize_request_body(&mut body)
770            .expect("finalize should succeed");
771        assert_eq!(body["tool_choice"], "none", "`none` is already compatible");
772
773        let mut body = serde_json::json!({
774            "model": MISTRAL_SMALL,
775            "messages": [{"role": "user", "content": "hi"}],
776            "tool_choice": "required",
777            "tools": [{"type": "function", "function": {"name": "add", "parameters": {}}}],
778            "response_format": {"type": "text"}
779        });
780        MistralExt
781            .finalize_request_body(&mut body)
782            .expect("finalize should succeed");
783        assert_eq!(
784            body["tool_choice"], "any",
785            "a `text` response format is unconstrained; only the structured kinds conflict"
786        );
787    }
788
789    #[test]
790    fn finalize_rewrites_required_tool_choice_to_any() {
791        let mut body = serde_json::json!({
792            "model": "mistral-small-latest",
793            "messages": [{"role": "user", "content": "hi"}],
794            "tool_choice": "required"
795        });
796
797        MistralExt
798            .finalize_request_body(&mut body)
799            .expect("finalize should succeed");
800
801        assert_eq!(body["tool_choice"], "any");
802    }
803
804    #[test]
805    fn finalize_preserves_specific_function_tool_choice() {
806        let mut body = serde_json::json!({
807            "model": "mistral-small-latest",
808            "messages": [{"role": "user", "content": "hi"}],
809            "tool_choice": {"type": "function", "function": {"name": "beta"}}
810        });
811
812        MistralExt
813            .finalize_request_body(&mut body)
814            .expect("finalize should succeed");
815
816        assert_eq!(
817            body["tool_choice"],
818            serde_json::json!({"type": "function", "function": {"name": "beta"}})
819        );
820    }
821
822    #[test]
823    fn finalize_flattens_assistant_history_and_adds_prefix() {
824        let mut body = serde_json::json!({
825            "model": "mistral-small-latest",
826            "messages": [
827                {"role": "system", "content": [{"type": "text", "text": "Be brief."}]},
828                {"role": "user", "content": "hi"},
829                {
830                    "role": "assistant",
831                    "content": [{"type": "text", "text": "Hello."}],
832                    "reasoning_content": "hidden thoughts"
833                },
834                {
835                    "role": "assistant",
836                    "tool_calls": [{
837                        "id": "call_1",
838                        "type": "function",
839                        "function": {"name": "add", "arguments": "{}"}
840                    }]
841                }
842            ]
843        });
844
845        MistralExt
846            .finalize_request_body(&mut body)
847            .expect("finalize should succeed");
848
849        assert_eq!(body["messages"][0]["content"], "Be brief.");
850        assert_eq!(body["messages"][2]["content"], "Hello.");
851        assert_eq!(body["messages"][2]["prefix"], false);
852        assert!(
853            body["messages"][2].get("reasoning_content").is_none(),
854            "Mistral rejects unknown assistant fields; reasoning must be stripped"
855        );
856        assert_eq!(body["messages"][3]["content"], "");
857        assert_eq!(body["messages"][3]["prefix"], false);
858    }
859
860    /// Finalize a one-user-message body and return the message's `content`.
861    ///
862    /// These cells are unit tests rather than cassettes because the behaviour
863    /// under test is that **no request is built** — there is no traffic to
864    /// record for content that is rejected before the wire. The cells covering
865    /// content that *is* sent are recorded, in
866    /// `tests/providers/mistral/multimodal_content.rs`.
867    fn finalized_content(parts: serde_json::Value) -> Result<serde_json::Value, CompletionError> {
868        let mut body = serde_json::json!({
869            "model": MISTRAL_SMALL,
870            "messages": [{"role": "user", "content": parts}],
871        });
872        MistralExt.finalize_request_body(&mut body)?;
873        Ok(body["messages"][0]["content"].clone())
874    }
875
876    /// Video has no Mistral chunk — the API's own content discriminator does
877    /// not list one — so it must fail rather than be flattened away.
878    #[test]
879    fn finalize_rejects_video_content() {
880        let error = finalized_content(serde_json::json!([
881            {"type": "text", "text": "Describe this."},
882            {"type": "video_url", "video_url": {"url": "data:video/mp4;base64,AAAA"}}
883        ]))
884        .expect_err("video content must not be dropped from the request");
885
886        assert!(matches!(error, CompletionError::RequestError(_)));
887        let rendered = error.to_string();
888        assert!(rendered.contains("video_url"), "{rendered}");
889    }
890
891    /// An unrecognized part type fails closed, so a content kind added to the
892    /// shared conversion later cannot start disappearing silently.
893    #[test]
894    fn finalize_rejects_unrecognized_and_untyped_parts() {
895        let error = finalized_content(serde_json::json!([
896            {"type": "text", "text": "hi"},
897            {"type": "some_future_part", "some_future_part": {}}
898        ]))
899        .expect_err("an unmodelled part must not be dropped");
900        assert!(matches!(error, CompletionError::RequestError(_)));
901
902        let error = finalized_content(serde_json::json!([
903            {"type": "text", "text": "hi"},
904            {"payload": "no type tag at all"}
905        ]))
906        .expect_err("an untyped part must not be dropped");
907        assert!(error.to_string().contains("untyped"), "{error}");
908    }
909
910    /// OpenAI's file part must carry something convertible; a part with
911    /// neither inline bytes nor an id names no document at all.
912    #[test]
913    fn finalize_rejects_a_file_part_with_no_payload() {
914        let error = finalized_content(serde_json::json!([
915            {"type": "text", "text": "hi"},
916            {"type": "file", "file": {"filename": "empty.pdf"}}
917        ]))
918        .expect_err("a file part naming no document must not be dropped");
919        assert!(matches!(error, CompletionError::RequestError(_)));
920    }
921
922    /// An audio part whose payload is neither a base64 string nor an object
923    /// carrying one cannot be rendered as Mistral's audio chunk.
924    #[test]
925    fn finalize_rejects_an_audio_part_with_no_payload() {
926        let error = finalized_content(serde_json::json!([
927            {"type": "text", "text": "hi"},
928            {"type": "input_audio", "input_audio": {"format": "mp3"}}
929        ]))
930        .expect_err("an audio part carrying no data must not be dropped");
931        assert!(matches!(error, CompletionError::RequestError(_)));
932    }
933
934    /// The document conversions, pinned as exact wire shapes.
935    ///
936    /// The `document_url` half is also proven live, by the recorded cells that
937    /// read `BANANA-7391` back out of an attached PDF. The `file`/`file_id`
938    /// half is pinned by shape only: exercising it end to end would mean
939    /// uploading a file and committing its account-scoped, expiring id to a
940    /// fixture, so this cell is the whole of its coverage.
941    #[test]
942    fn finalize_maps_openai_file_parts_onto_mistral_chunks() {
943        let content = finalized_content(serde_json::json!([
944            {"type": "text", "text": "Read these."},
945            {"type": "file", "file": {
946                "file_data": "data:application/pdf;base64,JVBERi0xLjQK",
947                "filename": "document.pdf"
948            }},
949            {"type": "file", "file": {"file_id": "00000000-0000-0000-0000-000000000000"}}
950        ]))
951        .expect("file parts should convert");
952
953        assert_eq!(
954            content,
955            serde_json::json!([
956                {"type": "text", "text": "Read these."},
957                {
958                    "type": "document_url",
959                    "document_url": "data:application/pdf;base64,JVBERi0xLjQK",
960                    "document_name": "document.pdf"
961                },
962                // Mistral's file chunk names the id at the top level; OpenAI's
963                // nesting under `file` is rejected as an extra field.
964                {"type": "file", "file_id": "00000000-0000-0000-0000-000000000000"}
965            ])
966        );
967    }
968
969    /// Audio collapses to Mistral's documented bare-string payload, and the
970    /// image chunk forwards unchanged because Mistral accepts rig's object.
971    #[test]
972    fn finalize_maps_audio_and_image_parts_onto_mistral_chunks() {
973        let content = finalized_content(serde_json::json!([
974            {"type": "input_audio", "input_audio": {"data": "SUQzBAA=", "format": "mp3"}},
975            {"type": "image_url", "image_url": {"url": "https://example.com/cat.png", "detail": "auto"}}
976        ]))
977        .expect("audio and image parts should convert");
978
979        assert_eq!(
980            content,
981            serde_json::json!([
982                {"type": "input_audio", "input_audio": "SUQzBAA="},
983                {"type": "image_url", "image_url": {"url": "https://example.com/cat.png", "detail": "auto"}}
984            ])
985        );
986    }
987
988    /// A refusal travelling beside a chunk becomes a text chunk: Mistral's
989    /// schema has no `refusal` field, and every chunk forbids unknown keys.
990    #[test]
991    fn finalize_retags_a_refusal_beside_a_chunk_as_text() {
992        let content = finalized_content(serde_json::json!([
993            {"type": "refusal", "refusal": "I cannot help with that."},
994            {"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}}
995        ]))
996        .expect("a refusal beside a chunk should convert");
997
998        assert_eq!(
999            content[0],
1000            serde_json::json!({"type": "text", "text": "I cannot help with that."})
1001        );
1002    }
1003
1004    /// Text-only content keeps the plain-string form every existing Mistral
1005    /// fixture pins — including a refusal-only message, which the shared
1006    /// flattening has always treated as text.
1007    #[test]
1008    fn finalize_still_flattens_text_only_content() {
1009        assert_eq!(
1010            finalized_content(serde_json::json!([
1011                {"type": "text", "text": "First."},
1012                {"type": "text", "text": "Second."}
1013            ]))
1014            .expect("text-only content should flatten"),
1015            serde_json::json!("First.Second.")
1016        );
1017
1018        assert_eq!(
1019            finalized_content(serde_json::json!([
1020                {"type": "text", "text": "Partly: "},
1021                {"type": "refusal", "refusal": "I cannot help with that."}
1022            ]))
1023            .expect("refusal content should flatten"),
1024            serde_json::json!("Partly: I cannot help with that.")
1025        );
1026
1027        // Content that is already a plain string is left exactly as-is.
1028        assert_eq!(
1029            finalized_content(serde_json::json!("already a string"))
1030                .expect("string content should pass through"),
1031            serde_json::json!("already a string")
1032        );
1033
1034        // An empty array still collapses to the empty string it always did.
1035        assert_eq!(
1036            finalized_content(serde_json::json!([])).expect("empty content should flatten"),
1037            serde_json::json!("")
1038        );
1039    }
1040
1041    /// Textuality is decided on the `type` tag, not on the presence of a
1042    /// `text` key. A part that names a chunk kind is that kind even if it also
1043    /// carries text — deciding on the key alone would flatten the chunk away,
1044    /// which is the silent drop this path exists to prevent.
1045    ///
1046    /// The stray key is *dropped*, not forwarded: every Mistral chunk forbids
1047    /// unknown fields, so carrying it through would 422 the whole request and
1048    /// lose the image just as surely.
1049    #[test]
1050    fn finalize_renders_a_chunk_that_also_carries_text_as_its_own_kind() {
1051        let content = finalized_content(serde_json::json!([
1052            {"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}, "text": "cat"}
1053        ]))
1054        .expect("a tagged image part should convert");
1055
1056        assert_eq!(
1057            content,
1058            serde_json::json!([
1059                {"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}}
1060            ]),
1061            "the image must reach the wire, in a chunk carrying only the fields Mistral names"
1062        );
1063    }
1064
1065    /// Finalizing an already-finalized body is a no-op. `finalize_request_body`
1066    /// is a public trait method, so a caller can reach it twice; the chunks
1067    /// this code emits must not read as content Mistral cannot carry.
1068    #[test]
1069    fn finalize_is_idempotent_over_the_chunks_it_emits() {
1070        let parts = serde_json::json!([
1071            {"type": "text", "text": "Read these."},
1072            {"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}},
1073            {"type": "input_audio", "input_audio": "SUQzBAA="},
1074            {"type": "document_url", "document_url": "data:application/pdf;base64,JVBERi0xLjQK",
1075             "document_name": "document.pdf"},
1076            {"type": "file", "file_id": "00000000-0000-0000-0000-000000000000"}
1077        ]);
1078
1079        let once = finalized_content(parts).expect("emitted chunks should convert");
1080        let twice = finalized_content(once.clone()).expect("a second pass should be a no-op");
1081
1082        assert_eq!(once, twice);
1083    }
1084
1085    /// An image part with no payload names no image at all.
1086    #[test]
1087    fn finalize_rejects_an_image_part_with_no_payload() {
1088        let error = finalized_content(serde_json::json!([
1089            {"type": "text", "text": "hi"},
1090            {"type": "image_url"}
1091        ]))
1092        .expect_err("an image part carrying no payload must not be dropped");
1093        assert!(matches!(error, CompletionError::RequestError(_)));
1094    }
1095}