use serde::{Deserialize, Deserializer, Serialize};
use super::client::{MistralExt, Usage};
use crate::providers::openai;
use crate::{
completion::{self, CompletionError},
json_utils,
};
pub const CODESTRAL: &str = "codestral-latest";
pub const MISTRAL_LARGE: &str = "mistral-large-latest";
#[deprecated(
note = "Mistral no longer serves this model. Pixtral is retired; use `MISTRAL_SMALL` or `MISTRAL_MEDIUM`, which are vision-capable"
)]
pub const PIXTRAL_LARGE: &str = "pixtral-large-latest";
#[deprecated(
note = "Mistral no longer serves this model. retired; no replacement in the live catalog"
)]
pub const MISTRAL_SABA: &str = "mistral-saba-latest";
pub const MINISTRAL_3B: &str = "ministral-3b-latest";
pub const MINISTRAL_8B: &str = "ministral-8b-latest";
pub const MISTRAL_SMALL: &str = "mistral-small-latest";
#[deprecated(
note = "Mistral no longer serves this model. Pixtral is retired; use `MINISTRAL_3B`, which is vision-capable"
)]
pub const PIXTRAL_SMALL: &str = "pixtral-12b-2409";
#[deprecated(
note = "Mistral no longer serves this model. retired; no replacement in the live catalog"
)]
pub const MISTRAL_NEMO: &str = "open-mistral-nemo";
#[deprecated(note = "Mistral no longer serves this model. retired; use `CODESTRAL`")]
pub const CODESTRAL_MAMBA: &str = "open-codestral-mamba";
pub type CompletionModel<H = reqwest::Client> =
openai::completion::GenericCompletionModel<MistralExt, H>;
pub type MistralStreamingCompletionResponse =
openai::StreamingCompletionResponse<super::client::Usage>;
fn mistral_content_value_to_text(value: serde_json::Value) -> String {
match value {
serde_json::Value::String(text) => text,
serde_json::Value::Array(parts) => openai::completion::joined_text_parts(&parts),
_ => String::new(),
}
}
fn deserialize_mistral_content_string<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
Ok(Option::<serde_json::Value>::deserialize(deserializer)?
.map(mistral_content_value_to_text)
.unwrap_or_default())
}
const TEXT_CHUNK: &str = "text";
const IMAGE_CHUNK: &str = "image_url";
const AUDIO_CHUNK: &str = "input_audio";
const DOCUMENT_CHUNK: &str = "document_url";
const FILE_CHUNK: &str = "file";
const REFUSAL_TYPE: &str = "refusal";
fn part_text(part: &serde_json::Value) -> Option<&str> {
part.get(TEXT_CHUNK)
.and_then(serde_json::Value::as_str)
.or_else(|| part.get(REFUSAL_TYPE).and_then(serde_json::Value::as_str))
}
fn is_text_part(part: &serde_json::Value) -> bool {
match part.get("type").and_then(serde_json::Value::as_str) {
Some(TEXT_CHUNK | REFUSAL_TYPE) => true,
Some(_) => false,
None => part_text(part).is_some(),
}
}
fn unsupported_content_error(what: &str) -> CompletionError {
crate::message::MessageError::ConversionError(format!(
"Mistral cannot carry {what}. Mistral messages accept text, `{IMAGE_CHUNK}`, \
`{AUDIO_CHUNK}`, `{DOCUMENT_CHUNK}` and `{FILE_CHUNK}` content; convert the content \
to one of those before sending it."
))
.into()
}
fn file_part_to_mistral_chunk(
part: &serde_json::Value,
) -> Result<serde_json::Value, CompletionError> {
let file = part.get(FILE_CHUNK);
let field = |name: &str| {
file.and_then(|file| file.get(name))
.and_then(serde_json::Value::as_str)
};
if let Some(file_id) = part.get("file_id").and_then(serde_json::Value::as_str) {
return Ok(serde_json::json!({"type": FILE_CHUNK, "file_id": file_id}));
}
if let Some(data) = field("file_data") {
Ok(match field("filename") {
Some(filename) => serde_json::json!({
"type": DOCUMENT_CHUNK,
DOCUMENT_CHUNK: data,
"document_name": filename,
}),
None => serde_json::json!({"type": DOCUMENT_CHUNK, DOCUMENT_CHUNK: data}),
})
} else if let Some(file_id) = field("file_id") {
Ok(serde_json::json!({"type": FILE_CHUNK, "file_id": file_id}))
} else {
Err(unsupported_content_error(
"a file content part carrying neither `file_data` nor `file_id`",
))
}
}
fn audio_part_to_mistral_chunk(
part: &serde_json::Value,
) -> Result<serde_json::Value, CompletionError> {
let payload = part.get(AUDIO_CHUNK).ok_or_else(|| {
unsupported_content_error("an audio content part carrying no `input_audio` payload")
})?;
let data = match payload {
serde_json::Value::String(data) => data.as_str(),
payload => payload
.get("data")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
unsupported_content_error(
"an audio content part whose `input_audio` payload is not base64 data",
)
})?,
};
Ok(serde_json::json!({"type": AUDIO_CHUNK, AUDIO_CHUNK: data}))
}
fn into_mistral_chunk(part: serde_json::Value) -> Result<serde_json::Value, CompletionError> {
fn text_chunk(part: &serde_json::Value) -> Result<serde_json::Value, CompletionError> {
let text = part_text(part)
.ok_or_else(|| unsupported_content_error("a text content part carrying no text"))?;
Ok(serde_json::json!({"type": TEXT_CHUNK, TEXT_CHUNK: text}))
}
match part.get("type").and_then(serde_json::Value::as_str) {
Some(TEXT_CHUNK | REFUSAL_TYPE) => text_chunk(&part),
Some(IMAGE_CHUNK) => {
let image = part.get(IMAGE_CHUNK).ok_or_else(|| {
unsupported_content_error("an image content part carrying no `image_url` payload")
})?;
Ok(serde_json::json!({"type": IMAGE_CHUNK, IMAGE_CHUNK: image}))
}
Some(AUDIO_CHUNK) => audio_part_to_mistral_chunk(&part),
Some(FILE_CHUNK) => file_part_to_mistral_chunk(&part),
Some(DOCUMENT_CHUNK) => {
let url = part.get(DOCUMENT_CHUNK).ok_or_else(|| {
unsupported_content_error("a document content part carrying no `document_url`")
})?;
Ok(match part.get("document_name") {
Some(name) => serde_json::json!({
"type": DOCUMENT_CHUNK, DOCUMENT_CHUNK: url, "document_name": name,
}),
None => serde_json::json!({"type": DOCUMENT_CHUNK, DOCUMENT_CHUNK: url}),
})
}
Some(kind) => Err(unsupported_content_error(&format!(
"`{kind}` message content"
))),
None if part_text(&part).is_some() => text_chunk(&part),
None => Err(unsupported_content_error("untyped message content")),
}
}
pub(super) fn normalize_request_content(
content: &mut serde_json::Value,
) -> Result<(), CompletionError> {
let Some(parts) = content.as_array() else {
return Ok(());
};
if parts.iter().all(is_text_part) {
openai::completion::flatten_text_content_parts(content, "", false);
return Ok(());
}
if let Some(parts) = content.as_array_mut() {
for part in parts {
*part = into_mistral_chunk(part.take())?;
}
}
Ok(())
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Choice {
pub index: usize,
pub message: Message,
pub logprobs: Option<serde_json::Value>,
pub finish_reason: String,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
#[serde(tag = "role", rename_all = "lowercase")]
pub enum Message {
User {
content: String,
},
Assistant {
#[serde(default, deserialize_with = "deserialize_mistral_content_string")]
content: String,
#[serde(
default,
deserialize_with = "json_utils::null_or_default",
skip_serializing_if = "Vec::is_empty"
)]
tool_calls: Vec<ToolCall>,
#[serde(default)]
prefix: bool,
},
System {
content: String,
},
Tool {
#[serde(skip_serializing_if = "String::is_empty")]
name: String,
content: String,
tool_call_id: String,
},
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct ToolCall {
pub id: String,
#[serde(default)]
pub r#type: ToolType,
pub function: Function,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct Function {
pub name: String,
#[serde(with = "json_utils::stringified_json")]
pub arguments: serde_json::Value,
}
#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Clone)]
#[serde(rename_all = "lowercase")]
pub enum ToolType {
#[default]
Function,
}
#[derive(Debug, Deserialize, Clone, Serialize)]
pub struct CompletionResponse {
pub id: String,
pub object: String,
pub created: u64,
pub model: String,
pub system_fingerprint: Option<String>,
#[serde(
deserialize_with = "crate::providers::internal::openai_chat_completions_compatible::deserialize_choices_dropping_incomplete_tool_calls"
)]
pub choices: Vec<Choice>,
pub usage: Option<Usage>,
}
impl crate::telemetry::ProviderResponseExt for CompletionResponse {
type Usage = Usage;
fn get_response_id(&self) -> Option<String> {
Some(self.id.clone())
}
fn get_response_model_name(&self) -> Option<String> {
Some(self.model.clone())
}
fn get_text_response(&self) -> Option<String> {
let res = self
.choices
.iter()
.filter_map(|choice| match choice.message {
Message::Assistant { ref content, .. } => {
if content.is_empty() {
None
} else {
Some(content.to_string())
}
}
_ => None,
})
.collect::<Vec<String>>()
.join("\n");
if res.is_empty() { None } else { Some(res) }
}
fn get_usage(&self) -> Option<Self::Usage> {
self.usage.clone()
}
}
impl crate::completion::NormalizeCompletionResponse for CompletionResponse {
fn normalize(self, provider: &str) -> Result<completion::CompletionResponse, CompletionError> {
use crate::providers::internal::openai_chat_completions_compatible as compat;
let usage = self
.usage
.as_ref()
.map(completion::Usage::from)
.unwrap_or_default();
compat::normalize_openai_response(
provider,
&self.choices,
Some(self.id.as_str()),
Some(self.model.as_str()),
usage,
|choice| choice.finish_reason.as_str(),
|choice| match &choice.message {
Message::Assistant {
content,
tool_calls,
..
} => Some(compat::text_then_tool_calls(
content,
content.is_empty(),
tool_calls.iter().map(|call| {
(
call.id.as_str(),
call.function.name.as_str(),
call.function.arguments.clone(),
)
}),
)),
_ => None,
},
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::completion::NormalizeCompletionResponse as _;
use crate::providers::openai::completion::OpenAICompatibleProvider;
#[test]
fn deserializes_response_with_array_and_null_content() {
let data = r#"{
"id": "cmpl-1",
"object": "chat.completion",
"created": 1,
"model": "mistral-small-latest",
"system_fingerprint": null,
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "Hello"}, {"type": "text", "text": " world"}]
},
"logprobs": null,
"finish_reason": "stop"
},
{
"index": 1,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "add", "arguments": "{\"x\":1,\"y\":2}"}
}]
},
"logprobs": null,
"finish_reason": "tool_calls"
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
}"#;
let response: CompletionResponse =
serde_json::from_str(data).expect("response should deserialize");
match &response.choices[0].message {
Message::Assistant { content, .. } => assert_eq!(content, "Hello world"),
_ => panic!("expected assistant message"),
}
match &response.choices[1].message {
Message::Assistant {
content,
tool_calls,
..
} => {
assert_eq!(content, "");
assert_eq!(tool_calls[0].function.name, "add");
}
_ => panic!("expected assistant message"),
}
}
#[test]
fn usage_prefers_structured_cached_tokens_and_falls_back() {
let structured: Usage = serde_json::from_value(serde_json::json!({
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
"num_cached_tokens": 2,
"prompt_tokens_details": {"cached_tokens": 7}
}))
.expect("usage should deserialize");
assert_eq!(structured.cached_tokens(), 7);
let fallback: Usage = serde_json::from_value(serde_json::json!({
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
"num_cached_tokens": 2
}))
.expect("usage should deserialize");
assert_eq!(fallback.cached_tokens(), 2);
let aliased: Usage = serde_json::from_value(serde_json::json!({
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
"prompt_token_details": {"cached_tokens": 4}
}))
.expect("usage should deserialize");
assert_eq!(aliased.cached_tokens(), 4);
}
#[test]
fn usage_counts_audio_tokens_as_input() {
let usage: Usage = serde_json::from_value(serde_json::json!({
"prompt_audio_seconds": 0,
"prompt_tokens": 6,
"completion_tokens": 2,
"total_tokens": 383,
"prompt_tokens_details": {"cached_tokens": 0, "audio_tokens": 375}
}))
.expect("usage should deserialize");
assert_eq!(usage.audio_tokens(), 375);
assert_eq!(usage.input_tokens(), 381);
let normalized = crate::completion::Usage::from(&usage);
assert_eq!(normalized.input_tokens, 381);
assert_eq!(normalized.output_tokens, 2);
assert_eq!(
normalized.input_tokens + normalized.output_tokens,
normalized.total_tokens,
"the parts must add up to the total Mistral reported"
);
}
#[test]
fn usage_without_audio_is_unchanged() {
let usage: Usage = serde_json::from_value(serde_json::json!({
"prompt_tokens": 19, "completion_tokens": 2, "total_tokens": 21,
"prompt_tokens_details": {"cached_tokens": 0}
}))
.expect("usage should deserialize");
assert_eq!(usage.audio_tokens(), 0);
assert_eq!(crate::completion::Usage::from(&usage).input_tokens, 19);
}
#[test]
fn truncated_tool_arguments_do_not_destroy_the_response() {
let data = r#"{
"id": "cmpl-1", "object": "chat.completion", "created": 1,
"model": "mistral-small-latest", "system_fingerprint": null,
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "Recording that now.",
"tool_calls": [{
"id": "call_1", "type": "function",
"function": {"name": "record", "arguments": "{\"note\": \"How to bake sour"}
}]
},
"logprobs": null,
"finish_reason": "length"
}],
"usage": {"prompt_tokens": 30, "completion_tokens": 32, "total_tokens": 62}
}"#;
let response: CompletionResponse =
serde_json::from_str(data).expect("a truncated tool call must not fail the response");
let normalized = response
.normalize("mistral")
.expect("the turn must survive with its text and metadata");
assert_eq!(
normalized.finish_reason(),
Some(crate::completion::FinishReason::Length),
"the finish reason is what reports the truncation"
);
assert_eq!(normalized.usage.total_tokens, 62);
assert!(
normalized.choice.iter().all(|content| !matches!(
content,
crate::completion::AssistantContent::ToolCall(_)
)),
"a call with truncated arguments must not be handed to a tool"
);
}
#[test]
fn complete_tool_arguments_still_parse() {
let data = r#"{
"id": "cmpl-1", "object": "chat.completion", "created": 1,
"model": "mistral-small-latest", "system_fingerprint": null,
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": null, "tool_calls": [{
"id": "call_1", "type": "function",
"function": {"name": "add", "arguments": "{\"x\":1,\"y\":2}"}
}]},
"logprobs": null, "finish_reason": "tool_calls"
}],
"usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
}"#;
let normalized = serde_json::from_str::<CompletionResponse>(data)
.expect("response should deserialize")
.normalize("mistral")
.expect("a complete call should normalize");
assert!(
normalized
.choice
.iter()
.any(|content| matches!(content, crate::completion::AssistantContent::ToolCall(_))),
"a complete call must still reach the caller"
);
}
#[test]
fn malformed_completed_tool_arguments_still_fail() {
let data = r#"{
"id": "cmpl-1", "object": "chat.completion", "created": 1,
"model": "mistral-small-latest", "system_fingerprint": null,
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": null, "tool_calls": [{
"id": "call_1", "type": "function",
"function": {"name": "add", "arguments": "{\"x\":"}
}]},
"logprobs": null, "finish_reason": "tool_calls"
}],
"usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
}"#;
assert!(
serde_json::from_str::<CompletionResponse>(data).is_err(),
"ordinary malformed tool output must remain loud"
);
}
#[test]
fn finalize_relaxes_a_forced_tool_choice_beside_a_response_format() {
let mut body = serde_json::json!({
"model": MISTRAL_SMALL,
"messages": [{"role": "user", "content": "hi"}],
"tool_choice": "required",
"tools": [{"type": "function", "function": {"name": "add", "parameters": {}}}],
"response_format": {"type": "json_schema", "json_schema": {"name": "Plan"}}
});
MistralExt
.finalize_request_body(&mut body)
.expect("finalize should succeed");
assert_eq!(body["tool_choice"], "auto");
assert!(
body.get("response_format").is_some(),
"the caller's schema must survive; relaxing the choice is what gives way"
);
let mut body = serde_json::json!({
"model": MISTRAL_SMALL,
"messages": [{"role": "user", "content": "hi"}],
"tool_choice": {"type": "function", "function": {"name": "add"}},
"tools": [{"type": "function", "function": {"name": "add", "parameters": {}}}],
"response_format": {"type": "json_object"}
});
MistralExt
.finalize_request_body(&mut body)
.expect("finalize should succeed");
assert_eq!(body["tool_choice"], "auto");
}
#[test]
fn finalize_leaves_a_forced_tool_choice_alone_without_a_response_format() {
let mut body = serde_json::json!({
"model": MISTRAL_SMALL,
"messages": [{"role": "user", "content": "hi"}],
"tool_choice": "required",
"tools": [{"type": "function", "function": {"name": "add", "parameters": {}}}]
});
MistralExt
.finalize_request_body(&mut body)
.expect("finalize should succeed");
assert_eq!(body["tool_choice"], "any", "still just the dialect rename");
let mut body = serde_json::json!({
"model": MISTRAL_SMALL,
"messages": [{"role": "user", "content": "hi"}],
"tool_choice": "none",
"tools": [{"type": "function", "function": {"name": "add", "parameters": {}}}],
"response_format": {"type": "json_object"}
});
MistralExt
.finalize_request_body(&mut body)
.expect("finalize should succeed");
assert_eq!(body["tool_choice"], "none", "`none` is already compatible");
let mut body = serde_json::json!({
"model": MISTRAL_SMALL,
"messages": [{"role": "user", "content": "hi"}],
"tool_choice": "required",
"tools": [{"type": "function", "function": {"name": "add", "parameters": {}}}],
"response_format": {"type": "text"}
});
MistralExt
.finalize_request_body(&mut body)
.expect("finalize should succeed");
assert_eq!(
body["tool_choice"], "any",
"a `text` response format is unconstrained; only the structured kinds conflict"
);
}
#[test]
fn finalize_rewrites_required_tool_choice_to_any() {
let mut body = serde_json::json!({
"model": "mistral-small-latest",
"messages": [{"role": "user", "content": "hi"}],
"tool_choice": "required"
});
MistralExt
.finalize_request_body(&mut body)
.expect("finalize should succeed");
assert_eq!(body["tool_choice"], "any");
}
#[test]
fn finalize_preserves_specific_function_tool_choice() {
let mut body = serde_json::json!({
"model": "mistral-small-latest",
"messages": [{"role": "user", "content": "hi"}],
"tool_choice": {"type": "function", "function": {"name": "beta"}}
});
MistralExt
.finalize_request_body(&mut body)
.expect("finalize should succeed");
assert_eq!(
body["tool_choice"],
serde_json::json!({"type": "function", "function": {"name": "beta"}})
);
}
#[test]
fn finalize_flattens_assistant_history_and_adds_prefix() {
let mut body = serde_json::json!({
"model": "mistral-small-latest",
"messages": [
{"role": "system", "content": [{"type": "text", "text": "Be brief."}]},
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": [{"type": "text", "text": "Hello."}],
"reasoning_content": "hidden thoughts"
},
{
"role": "assistant",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "add", "arguments": "{}"}
}]
}
]
});
MistralExt
.finalize_request_body(&mut body)
.expect("finalize should succeed");
assert_eq!(body["messages"][0]["content"], "Be brief.");
assert_eq!(body["messages"][2]["content"], "Hello.");
assert_eq!(body["messages"][2]["prefix"], false);
assert!(
body["messages"][2].get("reasoning_content").is_none(),
"Mistral rejects unknown assistant fields; reasoning must be stripped"
);
assert_eq!(body["messages"][3]["content"], "");
assert_eq!(body["messages"][3]["prefix"], false);
}
fn finalized_content(parts: serde_json::Value) -> Result<serde_json::Value, CompletionError> {
let mut body = serde_json::json!({
"model": MISTRAL_SMALL,
"messages": [{"role": "user", "content": parts}],
});
MistralExt.finalize_request_body(&mut body)?;
Ok(body["messages"][0]["content"].clone())
}
#[test]
fn finalize_rejects_video_content() {
let error = finalized_content(serde_json::json!([
{"type": "text", "text": "Describe this."},
{"type": "video_url", "video_url": {"url": "data:video/mp4;base64,AAAA"}}
]))
.expect_err("video content must not be dropped from the request");
assert!(matches!(error, CompletionError::RequestError(_)));
let rendered = error.to_string();
assert!(rendered.contains("video_url"), "{rendered}");
}
#[test]
fn finalize_rejects_unrecognized_and_untyped_parts() {
let error = finalized_content(serde_json::json!([
{"type": "text", "text": "hi"},
{"type": "some_future_part", "some_future_part": {}}
]))
.expect_err("an unmodelled part must not be dropped");
assert!(matches!(error, CompletionError::RequestError(_)));
let error = finalized_content(serde_json::json!([
{"type": "text", "text": "hi"},
{"payload": "no type tag at all"}
]))
.expect_err("an untyped part must not be dropped");
assert!(error.to_string().contains("untyped"), "{error}");
}
#[test]
fn finalize_rejects_a_file_part_with_no_payload() {
let error = finalized_content(serde_json::json!([
{"type": "text", "text": "hi"},
{"type": "file", "file": {"filename": "empty.pdf"}}
]))
.expect_err("a file part naming no document must not be dropped");
assert!(matches!(error, CompletionError::RequestError(_)));
}
#[test]
fn finalize_rejects_an_audio_part_with_no_payload() {
let error = finalized_content(serde_json::json!([
{"type": "text", "text": "hi"},
{"type": "input_audio", "input_audio": {"format": "mp3"}}
]))
.expect_err("an audio part carrying no data must not be dropped");
assert!(matches!(error, CompletionError::RequestError(_)));
}
#[test]
fn finalize_maps_openai_file_parts_onto_mistral_chunks() {
let content = finalized_content(serde_json::json!([
{"type": "text", "text": "Read these."},
{"type": "file", "file": {
"file_data": "data:application/pdf;base64,JVBERi0xLjQK",
"filename": "document.pdf"
}},
{"type": "file", "file": {"file_id": "00000000-0000-0000-0000-000000000000"}}
]))
.expect("file parts should convert");
assert_eq!(
content,
serde_json::json!([
{"type": "text", "text": "Read these."},
{
"type": "document_url",
"document_url": "data:application/pdf;base64,JVBERi0xLjQK",
"document_name": "document.pdf"
},
{"type": "file", "file_id": "00000000-0000-0000-0000-000000000000"}
])
);
}
#[test]
fn finalize_maps_audio_and_image_parts_onto_mistral_chunks() {
let content = finalized_content(serde_json::json!([
{"type": "input_audio", "input_audio": {"data": "SUQzBAA=", "format": "mp3"}},
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png", "detail": "auto"}}
]))
.expect("audio and image parts should convert");
assert_eq!(
content,
serde_json::json!([
{"type": "input_audio", "input_audio": "SUQzBAA="},
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png", "detail": "auto"}}
])
);
}
#[test]
fn finalize_retags_a_refusal_beside_a_chunk_as_text() {
let content = finalized_content(serde_json::json!([
{"type": "refusal", "refusal": "I cannot help with that."},
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}}
]))
.expect("a refusal beside a chunk should convert");
assert_eq!(
content[0],
serde_json::json!({"type": "text", "text": "I cannot help with that."})
);
}
#[test]
fn finalize_still_flattens_text_only_content() {
assert_eq!(
finalized_content(serde_json::json!([
{"type": "text", "text": "First."},
{"type": "text", "text": "Second."}
]))
.expect("text-only content should flatten"),
serde_json::json!("First.Second.")
);
assert_eq!(
finalized_content(serde_json::json!([
{"type": "text", "text": "Partly: "},
{"type": "refusal", "refusal": "I cannot help with that."}
]))
.expect("refusal content should flatten"),
serde_json::json!("Partly: I cannot help with that.")
);
assert_eq!(
finalized_content(serde_json::json!("already a string"))
.expect("string content should pass through"),
serde_json::json!("already a string")
);
assert_eq!(
finalized_content(serde_json::json!([])).expect("empty content should flatten"),
serde_json::json!("")
);
}
#[test]
fn finalize_renders_a_chunk_that_also_carries_text_as_its_own_kind() {
let content = finalized_content(serde_json::json!([
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}, "text": "cat"}
]))
.expect("a tagged image part should convert");
assert_eq!(
content,
serde_json::json!([
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}}
]),
"the image must reach the wire, in a chunk carrying only the fields Mistral names"
);
}
#[test]
fn finalize_is_idempotent_over_the_chunks_it_emits() {
let parts = serde_json::json!([
{"type": "text", "text": "Read these."},
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}},
{"type": "input_audio", "input_audio": "SUQzBAA="},
{"type": "document_url", "document_url": "data:application/pdf;base64,JVBERi0xLjQK",
"document_name": "document.pdf"},
{"type": "file", "file_id": "00000000-0000-0000-0000-000000000000"}
]);
let once = finalized_content(parts).expect("emitted chunks should convert");
let twice = finalized_content(once.clone()).expect("a second pass should be a no-op");
assert_eq!(once, twice);
}
#[test]
fn finalize_rejects_an_image_part_with_no_payload() {
let error = finalized_content(serde_json::json!([
{"type": "text", "text": "hi"},
{"type": "image_url"}
]))
.expect_err("an image part carrying no payload must not be dropped");
assert!(matches!(error, CompletionError::RequestError(_)));
}
}