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
10pub const CODESTRAL: &str = "codestral-latest";
12pub const MISTRAL_LARGE: &str = "mistral-large-latest";
14#[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#[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";
30pub const MINISTRAL_3B: &str = "ministral-3b-latest";
32pub const MINISTRAL_8B: &str = "ministral-8b-latest";
34
35pub const MISTRAL_SMALL: &str = "mistral-small-latest";
37#[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#[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#[deprecated(note = "Mistral no longer serves this model. retired; use `CODESTRAL`")]
58pub const CODESTRAL_MAMBA: &str = "open-codestral-mamba";
59
60pub type CompletionModel<H = reqwest::Client> =
62 openai::completion::GenericCompletionModel<MistralExt, H>;
63
64pub type MistralStreamingCompletionResponse =
69 openai::StreamingCompletionResponse<super::client::Usage>;
70
71fn 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
92const 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";
102const REFUSAL_TYPE: &str = "refusal";
105
106fn 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
114fn 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
139fn 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 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 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
185fn 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
216fn into_mistral_chunk(part: serde_json::Value) -> Result<serde_json::Value, CompletionError> {
222 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 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 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 None if part_text(&part).is_some() => text_chunk(&part),
266 None => Err(unsupported_content_error("untyped message content")),
267 }
268}
269
270pub(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 openai::completion::flatten_text_content_parts(content, "", false);
303 return Ok(());
304 }
305
306 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#[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 #[serde(skip_serializing_if = "String::is_empty")]
351 name: String,
352 content: String,
354 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
431impl 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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 {"type": "file", "file_id": "00000000-0000-0000-0000-000000000000"}
965 ])
966 );
967 }
968
969 #[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 #[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 #[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 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 assert_eq!(
1036 finalized_content(serde_json::json!([])).expect("empty content should flatten"),
1037 serde_json::json!("")
1038 );
1039 }
1040
1041 #[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 #[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 #[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}