use std::collections::HashMap;
use llmsdk_provider::ProviderError;
use llmsdk_provider::language_model::{
Content, GenerateResponse, GenerateResult, ReasoningPart, ResponseMetadata, TextPart,
ToolCallPart,
};
use llmsdk_provider::shared::{Headers, RequestInfo, Warning};
use llmsdk_provider_utils::time::rfc3339_from_unix_seconds;
use super::finish_reason;
use super::usage;
use super::wire::{ChatResponse, MistralContent, MistralContentPart, MistralThinkingChunk};
pub(crate) fn parse_response(
response: ChatResponse,
headers: HashMap<String, String>,
request_body: Option<serde_json::Value>,
warnings: Vec<Warning>,
) -> Result<GenerateResult, ProviderError> {
let choice = response
.choices
.into_iter()
.next()
.ok_or_else(ProviderError::no_content_generated)?;
let mut content: Vec<Content> = Vec::new();
if let Some(c) = choice.message.content {
match c {
MistralContent::Parts(parts) => {
for part in parts {
match part {
MistralContentPart::Text { text } if !text.is_empty() => {
content.push(Content::Text(TextPart {
text,
provider_options: None,
}));
}
MistralContentPart::Thinking { thinking } => {
let reasoning = collect_thinking_text(&thinking);
if !reasoning.is_empty() {
content.push(Content::Reasoning(ReasoningPart {
text: reasoning,
provider_options: None,
}));
}
}
MistralContentPart::Text { .. }
| MistralContentPart::ImageUrl { .. }
| MistralContentPart::Reference { .. } => {}
}
}
}
MistralContent::Text(text) => {
if !text.is_empty() {
content.push(Content::Text(TextPart {
text,
provider_options: None,
}));
}
}
}
}
if let Some(tool_calls) = choice.message.tool_calls {
for tc in tool_calls {
let input = serde_json::from_str::<serde_json::Value>(&tc.function.arguments)
.unwrap_or(serde_json::Value::String(tc.function.arguments.clone()));
content.push(Content::ToolCall(ToolCallPart {
tool_call_id: tc.id,
tool_name: tc.function.name,
input,
provider_executed: None,
dynamic: None,
provider_options: None,
}));
}
}
let usage_value = response
.usage
.as_ref()
.map_or_else(usage::zero, usage::convert);
let finish = finish_reason::map(choice.finish_reason.as_deref());
let response_meta = GenerateResponse {
metadata: ResponseMetadata {
id: response.id,
timestamp: response.created.map(rfc3339_from_unix_seconds),
model_id: response.model,
headers: Some(headers_to_provider(headers)),
},
body: None,
};
Ok(GenerateResult {
content,
finish_reason: finish,
usage: usage_value,
provider_metadata: None,
request: request_body.map(|body| RequestInfo { body: Some(body) }),
response: Some(response_meta),
warnings,
})
}
pub(crate) fn collect_thinking_text(chunks: &[MistralThinkingChunk]) -> String {
let mut s = String::new();
for chunk in chunks {
let MistralThinkingChunk::Text { text } = chunk;
s.push_str(text);
}
s
}
fn headers_to_provider(raw: HashMap<String, String>) -> Headers {
raw.into_iter().map(|(k, v)| (k, Some(v))).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chat::wire::{
ChatChoice, ChatChoiceMessage, WireFunctionCall, WireToolCall, WireToolCallKind,
};
use llmsdk_provider::language_model::FinishReasonKind;
fn empty_headers() -> HashMap<String, String> {
HashMap::new()
}
#[test]
fn parses_string_content() {
let resp = ChatResponse {
id: Some("r-1".into()),
created: Some(1),
model: Some("mistral-small-latest".into()),
choices: vec![ChatChoice {
message: ChatChoiceMessage {
content: Some(MistralContent::Text("hello".into())),
..Default::default()
},
finish_reason: Some("stop".into()),
_index: Some(0),
}],
..Default::default()
};
let result = parse_response(resp, empty_headers(), None, vec![]).expect("ok");
assert_eq!(result.content.len(), 1);
assert_eq!(result.finish_reason.unified, FinishReasonKind::Stop);
}
#[test]
fn parses_thinking_and_text_parts_in_order() {
let resp = ChatResponse {
choices: vec![ChatChoice {
message: ChatChoiceMessage {
content: Some(MistralContent::Parts(vec![
MistralContentPart::Thinking {
thinking: vec![MistralThinkingChunk::Text {
text: "thinking...".into(),
}],
},
MistralContentPart::Text {
text: "answer".into(),
},
])),
..Default::default()
},
finish_reason: Some("stop".into()),
..Default::default()
}],
..Default::default()
};
let result = parse_response(resp, empty_headers(), None, vec![]).unwrap();
assert_eq!(result.content.len(), 2);
assert!(matches!(result.content[0], Content::Reasoning(_)));
assert!(matches!(result.content[1], Content::Text(_)));
}
#[test]
fn parses_tool_calls() {
let resp = ChatResponse {
choices: vec![ChatChoice {
message: ChatChoiceMessage {
tool_calls: Some(vec![WireToolCall {
id: "call_x".into(),
kind: Some(WireToolCallKind::Function),
function: WireFunctionCall {
name: "get_weather".into(),
arguments: r#"{"city":"NYC"}"#.into(),
},
}]),
..Default::default()
},
finish_reason: Some("tool_calls".into()),
..Default::default()
}],
..Default::default()
};
let result = parse_response(resp, empty_headers(), None, vec![]).unwrap();
assert_eq!(result.content.len(), 1);
let Content::ToolCall(tc) = &result.content[0] else {
panic!("expected ToolCall");
};
assert_eq!(tc.tool_call_id, "call_x");
assert_eq!(tc.tool_name, "get_weather");
assert_eq!(tc.input["city"], "NYC");
assert_eq!(result.finish_reason.unified, FinishReasonKind::ToolCalls);
}
#[test]
fn empty_choices_yields_no_content_error() {
let resp = ChatResponse::default();
let err = parse_response(resp, empty_headers(), None, vec![]).unwrap_err();
assert!(format!("{err}").contains("no content"));
}
}