1use 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
19pub 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 body.insert("messages".into(), json!(oai::messages_to_openai(messages)));
31
32 apply_options(&mut body, options);
33
34 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 body.insert("stream_options".into(), json!({ "include_usage": true }));
54 }
55 Value::Object(body)
56}
57
58pub 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 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 body.insert("response_format".into(), json!(fmt));
94 }
95 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 for (k, v) in &options.additional_properties {
108 body.entry(k.clone()).or_insert_with(|| v.clone());
109 }
110}
111
112pub fn parse_response(value: &Value) -> ChatResponse {
118 oai::parse_response(value)
119}
120
121pub 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 #[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 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 #[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 #[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 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 }