Skip to main content

agent_framework_mistral/
convert.rs

1//! Conversion between framework types and the Mistral chat-completions wire
2//! format.
3//!
4//! Mistral's `POST /v1/chat/completions` endpoint is wire-compatible with
5//! OpenAI's Chat Completions API for the parts both providers share (message
6//! shape, function-tool shape, tool-call/response shape, token usage shape),
7//! so message conversion, tool conversion, and response/usage parsing are all
8//! reused verbatim from [`agent_framework_openai::convert`] — the same
9//! approach `agent-framework-azure` takes for Azure OpenAI. Request-option
10//! handling is *not* fully shared, because Mistral's supported option set
11//! differs from OpenAI's in real ways (see [`apply_options`]), so that part
12//! is implemented here instead of reused.
13
14use agent_framework_core::error::Error;
15use agent_framework_core::types::{ChatOptions, ChatResponse};
16use agent_framework_openai::convert as oai;
17use serde_json::{json, Map, Value};
18
19/// Build a full Mistral `POST /v1/chat/completions` request body.
20pub fn build_request(
21    messages: &[agent_framework_core::types::Message],
22    options: &ChatOptions,
23    model: &str,
24    stream: bool,
25) -> Value {
26    let mut body = Map::new();
27    body.insert("model".into(), json!(model));
28    // Message shape (text/image parts, tool_calls, tool results) is
29    // identical to OpenAI's, so this is reused verbatim.
30    body.insert("messages".into(), json!(oai::messages_to_openai(messages)));
31
32    apply_options(&mut body, options);
33
34    // Function-tool shape (`{"type":"function","function":{...}}`) and
35    // `tool_choice` (`"auto"`/`"none"`/`"required"`/named) are identical to
36    // OpenAI's; hosted-tool kinds (web search, code interpreter, ...) are not
37    // supported by the Mistral Chat Completions API and are skipped with a
38    // warning by `tools_to_openai` itself, which is exactly the "skip
39    // unsupported gracefully" behavior wanted here too.
40    let (tools, tool_choice) = oai::tools_to_openai(options);
41    if let Some(tools) = tools {
42        body.insert("tools".into(), tools);
43    }
44    if let Some(choice) = tool_choice {
45        body.insert("tool_choice".into(), choice);
46    }
47
48    if stream {
49        body.insert("stream".into(), json!(true));
50        // Mirrors OpenAI's `stream_options.include_usage`: Mistral's
51        // streaming Chat Completions API accepts the same field and, like
52        // OpenAI, emits a final usage-only chunk when it is set.
53        body.insert("stream_options".into(), json!({ "include_usage": true }));
54    }
55    Value::Object(body)
56}
57
58/// Apply the scalar [`ChatOptions`] fields Mistral's Chat Completions API
59/// actually supports onto a request body map.
60///
61/// Deliberately narrower than [`agent_framework_openai::convert::apply_options`]:
62/// * `temperature`, `top_p`, `max_tokens`, `stop`, `frequency_penalty`,
63///   `presence_penalty`, `response_format`, and `tool_choice`-adjacent
64///   `parallel_tool_calls` map straight across (documented Mistral request
65///   fields).
66/// * `seed` maps to Mistral's differently-named `random_seed` field rather
67///   than OpenAI's `seed`.
68/// * `store`, `metadata`, `logit_bias`, and `user` have no Mistral Chat
69///   Completions equivalent and are skipped gracefully (never sent) instead
70///   of being forwarded as unrecognized fields.
71pub fn apply_options(body: &mut Map<String, Value>, options: &ChatOptions) {
72    macro_rules! set {
73        ($key:literal, $val:expr) => {
74            if let Some(v) = $val {
75                body.insert($key.into(), json!(v));
76            }
77        };
78    }
79    set!("temperature", options.temperature);
80    set!("top_p", options.top_p);
81    set!("max_tokens", options.max_tokens);
82    set!("frequency_penalty", options.frequency_penalty);
83    set!("presence_penalty", options.presence_penalty);
84    // Mistral's random-seed parameter is named `random_seed`, not `seed`.
85    set!("random_seed", options.seed);
86    if let Some(stop) = &options.stop {
87        body.insert("stop".into(), json!(stop));
88    }
89    if let Some(fmt) = &options.response_format {
90        // `ResponseFormat` serializes to `{"type":"text"|"json_object"}` or
91        // `{"type":"json_schema","json_schema":{...}}`, which is exactly the
92        // shape Mistral's `response_format` field expects.
93        body.insert("response_format".into(), json!(fmt));
94    }
95    // Only meaningful alongside function tools, exactly like OpenAI's.
96    if let Some(allow) = options.allow_multiple_tool_calls {
97        if options
98            .tools
99            .iter()
100            .any(|t| t.kind == agent_framework_core::tools::ToolKind::Function)
101        {
102            body.insert("parallel_tool_calls".into(), json!(allow));
103        }
104    }
105    // `store`, `metadata`, `logit_bias`, `user`: no Mistral Chat Completions
106    // equivalent -- intentionally not forwarded.
107    for (k, v) in &options.additional_properties {
108        body.entry(k.clone()).or_insert_with(|| v.clone());
109    }
110}
111
112/// Parse a full (non-streaming) Mistral chat-completion response.
113///
114/// The response shape (`id`, `model`, `choices[0].message.{content,tool_calls}`,
115/// `choices[0].finish_reason`, `usage.{prompt_tokens,completion_tokens,total_tokens}`)
116/// is identical to OpenAI's, so parsing is reused verbatim.
117pub fn parse_response(value: &Value) -> ChatResponse {
118    oai::parse_response(value)
119}
120
121/// Classify a non-success Mistral Chat Completions API HTTP response into a
122/// granular [`Error`].
123///
124/// Mistral's documented error body is `{"object":"error","message":...,
125/// "type":...,"param":...,"code":...}` (OpenAI-shaped but without a nested
126/// `error` wrapper). This maps by HTTP status only:
127///
128/// * `401` / `403` -> [`Error::ServiceInvalidAuth`]
129/// * `400` / `422` -> [`Error::ServiceInvalidRequest`]
130/// * anything else — notably `408` / `429` / `5xx`, which the retry layer
131///   depends on — -> [`Error::ServiceStatus`], unchanged
132///
133/// Unlike OpenAI, Mistral has no documented content-filter-specific error
134/// marker to key off of (no `code: "content_filter"` convention), so this
135/// never constructs [`Error::ServiceContentFilter`] -- don't invent one.
136pub fn classify_mistral_error(
137    status: u16,
138    message: impl Into<String>,
139    retry_after: Option<f64>,
140) -> Error {
141    let message = message.into();
142    match status {
143        401 | 403 => Error::service_invalid_auth(message),
144        400 | 422 => Error::service_invalid_request(message),
145        _ => Error::service_status(status, message, retry_after),
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use agent_framework_core::tools::{ApprovalMode, ToolDefinition, ToolKind};
153    use agent_framework_core::types::{Message, ResponseFormat, ToolMode};
154
155    fn user(text: &str) -> Message {
156        Message::user(text)
157    }
158
159    // region: request building
160
161    #[test]
162    fn build_request_simple_text() {
163        let body = build_request(
164            &[user("Hello there")],
165            &ChatOptions::new(),
166            "mistral-large-latest",
167            false,
168        );
169        assert_eq!(
170            body,
171            json!({
172                "model": "mistral-large-latest",
173                "messages": [{ "role": "user", "content": "Hello there" }],
174            })
175        );
176    }
177
178    #[test]
179    fn build_request_temperature_max_tokens_top_p() {
180        let mut options = ChatOptions::new()
181            .with_temperature(0.4)
182            .with_max_tokens(256);
183        options.top_p = Some(0.8);
184        let body = build_request(&[user("hi")], &options, "mistral-small-latest", false);
185        assert_eq!(body["temperature"], json!(0.4_f32));
186        assert_eq!(body["max_tokens"], json!(256));
187        assert_eq!(body["top_p"], json!(0.8_f32));
188    }
189
190    #[test]
191    fn build_request_seed_maps_to_random_seed() {
192        let mut options = ChatOptions::new();
193        options.seed = Some(42);
194        let body = build_request(&[user("hi")], &options, "mistral-small-latest", false);
195        assert_eq!(body["random_seed"], json!(42));
196        assert!(body.get("seed").is_none());
197    }
198
199    #[test]
200    fn build_request_unsupported_options_are_skipped_gracefully() {
201        let mut options = ChatOptions::new();
202        options.store = Some(true);
203        options.user = Some("user-123".into());
204        options.metadata = Some(std::collections::HashMap::from([(
205            "k".to_string(),
206            "v".to_string(),
207        )]));
208        let body = build_request(&[user("hi")], &options, "mistral-small-latest", false);
209        assert!(body.get("store").is_none());
210        assert!(body.get("user").is_none());
211        assert!(body.get("metadata").is_none());
212    }
213
214    #[test]
215    fn build_request_stop_and_penalties() {
216        let mut options = ChatOptions::new();
217        options.stop = Some(vec!["STOP".into()]);
218        options.frequency_penalty = Some(0.1);
219        options.presence_penalty = Some(0.2);
220        let body = build_request(&[user("hi")], &options, "mistral-small-latest", false);
221        assert_eq!(body["stop"], json!(["STOP"]));
222        assert_eq!(body["frequency_penalty"], json!(0.1_f32));
223        assert_eq!(body["presence_penalty"], json!(0.2_f32));
224    }
225
226    #[test]
227    fn build_request_stream_flag_includes_usage_option() {
228        let body = build_request(
229            &[user("hi")],
230            &ChatOptions::new(),
231            "mistral-small-latest",
232            true,
233        );
234        assert_eq!(body["stream"], json!(true));
235        assert_eq!(body["stream_options"], json!({ "include_usage": true }));
236    }
237
238    #[test]
239    fn build_request_tools_and_tool_choice() {
240        let tool = ToolDefinition {
241            name: "get_weather".into(),
242            description: "Get the weather".into(),
243            parameters: json!({ "type": "object", "properties": {} }),
244            kind: ToolKind::Function,
245            approval_mode: ApprovalMode::NeverRequire,
246            executor: None,
247        };
248        let options = ChatOptions::new()
249            .with_tool(tool)
250            .with_tool_choice(ToolMode::Required(Some("get_weather".into())));
251        let body = build_request(&[user("hi")], &options, "mistral-large-latest", false);
252        assert_eq!(body["tools"][0]["type"], json!("function"));
253        assert_eq!(body["tools"][0]["function"]["name"], json!("get_weather"));
254        assert_eq!(
255            body["tool_choice"],
256            json!({ "type": "function", "function": { "name": "get_weather" } })
257        );
258    }
259
260    #[test]
261    fn build_request_hosted_tools_are_skipped_gracefully() {
262        let tool = ToolDefinition {
263            name: "web_search".into(),
264            description: String::new(),
265            parameters: json!({}),
266            kind: ToolKind::HostedWebSearch,
267            approval_mode: ApprovalMode::NeverRequire,
268            executor: None,
269        };
270        let options = ChatOptions::new().with_tool(tool);
271        let body = build_request(&[user("hi")], &options, "mistral-large-latest", false);
272        // No Mistral wire equivalent for hosted web search: dropped rather
273        // than sent as a bogus `tools[]` entry or erroring the request.
274        assert!(body.get("tools").is_none());
275    }
276
277    #[test]
278    fn build_request_parallel_tool_calls_only_with_function_tools() {
279        let mut options = ChatOptions::new();
280        options.allow_multiple_tool_calls = Some(false);
281        let body = build_request(&[user("hi")], &options, "mistral-large-latest", false);
282        assert!(body.get("parallel_tool_calls").is_none());
283
284        let tool = ToolDefinition {
285            name: "get_weather".into(),
286            description: String::new(),
287            parameters: json!({}),
288            kind: ToolKind::Function,
289            approval_mode: ApprovalMode::NeverRequire,
290            executor: None,
291        };
292        let mut options2 = ChatOptions::new().with_tool(tool);
293        options2.allow_multiple_tool_calls = Some(false);
294        let body2 = build_request(&[user("hi")], &options2, "mistral-large-latest", false);
295        assert_eq!(body2["parallel_tool_calls"], json!(false));
296    }
297
298    #[test]
299    fn build_request_response_format_json_object() {
300        let mut options = ChatOptions::new();
301        options.response_format = Some(ResponseFormat::JsonObject);
302        let body = build_request(&[user("hi")], &options, "mistral-large-latest", false);
303        assert_eq!(body["response_format"], json!({ "type": "json_object" }));
304    }
305
306    #[test]
307    fn build_request_response_format_json_schema() {
308        let mut options = ChatOptions::new();
309        options.response_format = Some(ResponseFormat::json_schema(
310            "Person",
311            json!({ "type": "object", "properties": { "name": { "type": "string" } } }),
312        ));
313        let body = build_request(&[user("hi")], &options, "mistral-large-latest", false);
314        assert_eq!(body["response_format"]["type"], json!("json_schema"));
315        assert_eq!(
316            body["response_format"]["json_schema"]["name"],
317            json!("Person")
318        );
319    }
320
321    // endregion
322
323    // region: response parsing (reuses agent-framework-openai::convert verbatim)
324
325    #[test]
326    fn parse_response_text_and_usage() {
327        let value = json!({
328            "id": "cmpl-123",
329            "model": "mistral-large-latest",
330            "choices": [{
331                "index": 0,
332                "message": { "role": "assistant", "content": "Hello!" },
333                "finish_reason": "stop",
334            }],
335            "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 },
336        });
337        let resp = parse_response(&value);
338        assert_eq!(resp.response_id.as_deref(), Some("cmpl-123"));
339        assert_eq!(resp.text(), "Hello!");
340        assert_eq!(
341            resp.finish_reason,
342            Some(agent_framework_core::types::FinishReason::stop())
343        );
344        let usage = resp.usage_details.unwrap();
345        assert_eq!(usage.input_token_count, Some(10));
346        assert_eq!(usage.output_token_count, Some(5));
347        assert_eq!(usage.total_token_count, Some(15));
348    }
349
350    #[test]
351    fn parse_response_tool_call() {
352        let value = json!({
353            "id": "cmpl-124",
354            "model": "mistral-large-latest",
355            "choices": [{
356                "index": 0,
357                "message": {
358                    "role": "assistant",
359                    "content": null,
360                    "tool_calls": [{
361                        "id": "call_1",
362                        "type": "function",
363                        "function": { "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" },
364                    }],
365                },
366                "finish_reason": "tool_calls",
367            }],
368        });
369        let resp = parse_response(&value);
370        assert_eq!(
371            resp.finish_reason,
372            Some(agent_framework_core::types::FinishReason::tool_calls())
373        );
374        let calls = resp.function_calls();
375        assert_eq!(calls.len(), 1);
376        assert_eq!(calls[0].call_id, "call_1");
377        assert_eq!(calls[0].name, "get_weather");
378        assert_eq!(
379            calls[0].parse_arguments().unwrap().get("city").unwrap(),
380            &json!("Paris")
381        );
382    }
383
384    // endregion
385
386    // region: classify_mistral_error
387
388    #[test]
389    fn classifies_401_and_403_as_invalid_auth() {
390        for status in [401, 403] {
391            let err = classify_mistral_error(status, format!("err {status}"), None);
392            assert!(
393                matches!(err, Error::ServiceInvalidAuth { .. }),
394                "status {status}: {err:?}"
395            );
396        }
397    }
398
399    #[test]
400    fn classifies_400_and_422_as_invalid_request() {
401        for status in [400, 422] {
402            let err = classify_mistral_error(status, format!("err {status}"), None);
403            assert!(
404                matches!(err, Error::ServiceInvalidRequest { .. }),
405                "status {status}: {err:?}"
406            );
407        }
408    }
409
410    #[test]
411    fn leaves_retryable_statuses_as_service_status() {
412        for status in [408, 429, 500, 503] {
413            let err = classify_mistral_error(status, format!("err {status}"), Some(2.0));
414            assert_eq!(err.status(), Some(status), "{err:?}");
415            assert_eq!(err.retry_after(), Some(2.0), "{err:?}");
416        }
417    }
418
419    #[test]
420    fn never_produces_content_filter() {
421        // Mistral has no documented content-filter-specific HTTP error
422        // marker, so this classification path must never invent one.
423        for status in [400, 401, 403, 404, 422, 429, 500] {
424            let err = classify_mistral_error(status, "err", None);
425            assert!(
426                !matches!(err, Error::ServiceContentFilter { .. }),
427                "status {status}: {err:?}"
428            );
429        }
430    }
431
432    // endregion
433}