1mod context;
4mod messages;
5mod tools;
6
7pub use context::{ToolPriority, ToolSearchContext};
8pub use messages::{convert_input_to_messages, extract_content};
9pub use tools::{convert_tools, convert_tool_choice};
10
11use crate::constants::MIN_MAX_TOKENS;
12use crate::error::ConversionError;
13use crate::providers::Provider;
14use crate::types::chat_api::{ChatRequest, StreamOptions};
15use crate::types::response_api::{ResponseRequest, ResponseTextConfig};
16use tracing::{debug, warn};
17
18fn to_chat_response_format(
19 text: Option<&ResponseTextConfig>,
20) -> Result<Option<serde_json::Value>, ConversionError> {
21 let Some(format) = text.and_then(|t| t.format.as_ref()) else {
22 return Ok(None);
23 };
24 match format.format_type.as_str() {
25 "json_schema" => {
26 let mut json_schema = serde_json::json!({
27 "name": format.name.clone().unwrap_or_else(|| "response_schema".to_string()),
28 "schema": format.schema.clone().unwrap_or_else(|| serde_json::json!({})),
29 });
30 if let Some(strict) = format.strict {
31 json_schema["strict"] = serde_json::json!(strict);
32 }
33 Ok(Some(serde_json::json!({
34 "type": "json_schema",
35 "json_schema": json_schema
36 })))
37 }
38 "json_object" => Ok(Some(serde_json::json!({
39 "type": "json_object"
40 }))),
41 "text" => Ok(Some(serde_json::json!({
42 "type": "text"
43 }))),
44 other => Err(ConversionError::InvalidFormat(format!(
45 "unsupported text.format.type: {other}"
46 ))),
47 }
48}
49
50pub fn response_to_chat(
52 response_req: ResponseRequest,
53 provider: &dyn Provider,
54 model_override: Option<&str>,
55 _tool_priority: ToolPriority,
56) -> Result<ChatRequest, ConversionError> {
57 let enforce_tool_result_adjacency = provider.name() == "minimax";
58 let (messages, extracted_tools) = convert_input_to_messages(
59 response_req.input,
60 response_req.instructions,
61 enforce_tool_result_adjacency,
62 )?;
63
64 let merged_tools = if extracted_tools.is_empty() {
66 response_req.tools
67 } else if response_req.tools.is_empty() {
68 extracted_tools
69 } else {
70 use crate::convert::request::context::merge_tools_map;
72 merge_tools_map(&response_req.tools, &extracted_tools)
73 };
74
75 let tools = convert_tools(merged_tools);
76 let tool_choice = convert_tool_choice(response_req.tool_choice);
77
78 let model = model_override
80 .map(|s| s.to_string())
81 .unwrap_or_else(|| provider.normalize_model(response_req.model));
82
83 let response_format = to_chat_response_format(response_req.text.as_ref())?;
84
85 let mut chat_req = ChatRequest {
90 model,
91 messages,
92 tools: Some(tools).filter(|t| !t.is_empty()),
93 tool_choice: Some(tool_choice),
94 stream: Some(response_req.stream),
95 temperature: response_req.temperature,
96 max_tokens: response_req.max_output_tokens.or(response_req.max_tokens),
97 top_p: response_req.top_p,
98 user: response_req.user,
99 stream_options: if response_req.stream {
100 Some(StreamOptions { include_usage: Some(true) })
101 } else {
102 None
103 },
104 frequency_penalty: None,
105 presence_penalty: None,
106 logit_bias: None,
107 logprobs: None,
108 top_logprobs: None,
109 n: None,
110 stop: None,
111 response_format,
112 reasoning_effort: response_req.reasoning.as_ref().and_then(|r| r.effort.clone()),
113 parallel_tool_calls: response_req.parallel_tool_calls,
114 seed: None,
115 service_tier: None,
116 web_search_options: None,
117 modalities: None,
118 prediction: None,
119 audio: None,
120 };
121
122 if let Some(max_tokens) = chat_req.max_tokens
125 && max_tokens < MIN_MAX_TOKENS {
126 warn!(
127 "[REQUEST_CONVERT] max_tokens {} below floor {}; raising to floor",
128 max_tokens, MIN_MAX_TOKENS
129 );
130 chat_req.max_tokens = Some(MIN_MAX_TOKENS);
131 }
132
133 provider.transform_request(&mut chat_req);
134
135 debug!(
136 "[REQUEST_CONVERT] converted request: model={}, messages={}, tools={}",
137 chat_req.model,
138 chat_req.messages.len(),
139 chat_req.tools.as_ref().map_or(0, |t| t.len())
140 );
141
142 Ok(chat_req)
143}
144
145#[cfg(test)]
146mod tests {
147 use std::collections::HashMap;
148
149 use super::*;
150 use crate::providers::glm::GLMProvider;
151 use crate::types::chat_api::MessageRole;
152 use crate::types::response_api::{
153 Content as ResponseContent, InputItem, InputItemOrString, InputItemType, ResponseReasoning,
154 ResponseRequest, ResponseTextConfig, ResponseTextFormat,
155 Tool, ToolChoice as ResponseToolChoice, ToolType,
156 };
157
158 fn make_request(input: InputItemOrString) -> ResponseRequest {
159 ResponseRequest {
160 model: "gpt-4o".to_string(),
161 input,
162 instructions: None,
163 tools: vec![],
164 tool_choice: ResponseToolChoice::Auto,
165 stream: false,
166 temperature: None,
167 max_tokens: None,
168 max_output_tokens: None,
169 top_p: None,
170 user: None,
171 reasoning: None,
172 text: None,
173 truncation: None,
174 store: None,
175 metadata: None,
176 previous_response_id: None,
177 parallel_tool_calls: None,
178 background: None,
179 }
180 }
181
182 #[test]
183 fn test_instructions_to_system_message() {
184 let mut request = make_request(InputItemOrString::String("Hello".to_string()));
185 request.instructions = Some("You are a helpful assistant.".to_string());
186
187 let provider = crate::providers::minimax::MiniMaxProvider;
188 let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
189
190 let first = chat_req.messages.first().unwrap();
191 assert_eq!(first.role, MessageRole::System);
192 assert_eq!(first.content.as_text(), "You are a helpful assistant.");
193
194 let second = chat_req.messages.get(1).unwrap();
195 assert_eq!(second.role, MessageRole::User);
196 assert_eq!(second.content.as_text(), "Hello");
197 }
198
199 #[test]
200 fn test_function_call_conversion() {
201 let request = make_request(InputItemOrString::Array(vec![InputItem {
202 id: Some("call_123".to_string()),
203 item_type: InputItemType::FunctionCall,
204 role: None,
205 content: None,
206 name: Some("get_weather".to_string()),
207 arguments: Some(r#"{"city":"Beijing"}"#.to_string()),
208 call_id: None,
209 output: None,
210 namespace: None,
211 tools: None,
212 }]));
213
214 let provider = crate::providers::minimax::MiniMaxProvider;
215 let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
216
217 let msg = chat_req.messages.first().unwrap();
218 assert_eq!(msg.role, MessageRole::Assistant);
219 assert!(msg.tool_calls.is_some());
220
221 let tc = msg.tool_calls.as_ref().unwrap().first().unwrap();
222 assert_eq!(tc.function.name, "get_weather");
223 assert_eq!(tc.function.arguments, r#"{"city":"Beijing"}"#);
224 }
225
226 #[test]
227 fn test_function_call_output() {
228 let request = make_request(InputItemOrString::Array(vec![
229 InputItem {
230 id: Some("call_123".to_string()),
231 item_type: InputItemType::FunctionCall,
232 role: None,
233 content: None,
234 name: Some("get_weather".to_string()),
235 arguments: Some(r#"{"city":"Beijing"}"#.to_string()),
236 call_id: None,
237 output: None,
238 namespace: None,
239 tools: None,
240 },
241 InputItem {
242 id: None,
243 item_type: InputItemType::FunctionCallOutput,
244 role: None,
245 content: None,
246 name: Some("get_weather".to_string()),
247 arguments: None,
248 call_id: Some("call_123".to_string()),
249 output: Some("25 degrees, sunny".to_string()),
250 namespace: None,
251 tools: None,
252 },
253 ]));
254
255 let provider = crate::providers::minimax::MiniMaxProvider;
256 let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
257
258 assert_eq!(chat_req.messages.len(), 2);
259
260 let assistant = &chat_req.messages[0];
261 assert_eq!(assistant.role, MessageRole::Assistant);
262 assert!(assistant.tool_calls.is_some());
263
264 let tool_msg = &chat_req.messages[1];
265 assert_eq!(tool_msg.role, MessageRole::Tool);
266 assert_eq!(tool_msg.tool_call_id.as_deref(), Some("call_123"));
267 assert_eq!(tool_msg.content.as_text(), "25 degrees, sunny");
268 }
269
270 #[test]
271 fn test_orphan_function_call_output_synthesizes_preceding_tool_call() {
272 let request = make_request(InputItemOrString::Array(vec![InputItem {
273 id: None,
274 item_type: InputItemType::FunctionCallOutput,
275 role: None,
276 content: None,
277 name: Some("get_weather".to_string()),
278 arguments: None,
279 call_id: Some("call_orphan".to_string()),
280 output: Some("sunny".to_string()),
281 namespace: None,
282 tools: None,
283 }]));
284
285 let provider = crate::providers::minimax::MiniMaxProvider;
286 let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
287 assert_eq!(chat_req.messages.len(), 2);
288
289 let assistant = &chat_req.messages[0];
290 assert_eq!(assistant.role, MessageRole::Assistant);
291 let tc = assistant
292 .tool_calls
293 .as_ref()
294 .and_then(|calls| calls.first())
295 .expect("synthetic tool call should exist");
296 assert_eq!(tc.id, "call_orphan");
297 assert_eq!(tc.function.name, "get_weather");
298
299 let tool_msg = &chat_req.messages[1];
300 assert_eq!(tool_msg.role, MessageRole::Tool);
301 assert_eq!(tool_msg.tool_call_id.as_deref(), Some("call_orphan"));
302 assert_eq!(tool_msg.content.as_text(), "sunny");
303 }
304
305 #[test]
306 fn test_assistant_message_merges_with_pending_tool_calls() {
307 let request = make_request(InputItemOrString::Array(vec![
308 InputItem {
309 id: Some("fc_1".to_string()),
310 item_type: InputItemType::FunctionCall,
311 role: None,
312 content: None,
313 name: Some("exec_command".to_string()),
314 arguments: Some(r#"{"cmd":"ls"}"#.to_string()),
315 call_id: Some("call_1".to_string()),
316 output: None,
317 namespace: None,
318 tools: None,
319 },
320 InputItem {
321 id: Some("msg_1".to_string()),
322 item_type: InputItemType::Message,
323 role: Some("assistant".to_string()),
324 content: Some(ResponseContent::String("我先看下目录".to_string())),
325 name: None,
326 arguments: None,
327 call_id: None,
328 output: None,
329 namespace: None,
330 tools: None,
331 },
332 InputItem {
333 id: Some("fco_1".to_string()),
334 item_type: InputItemType::FunctionCallOutput,
335 role: None,
336 content: None,
337 name: Some("exec_command".to_string()),
338 arguments: None,
339 call_id: Some("call_1".to_string()),
340 output: Some("ok".to_string()),
341 namespace: None,
342 tools: None,
343 },
344 ]));
345
346 let provider = crate::providers::minimax::MiniMaxProvider;
347 let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
348
349 assert_eq!(chat_req.messages.len(), 2);
350 let assistant = &chat_req.messages[0];
351 assert_eq!(assistant.role, MessageRole::Assistant);
352 assert_eq!(assistant.content.as_text(), "我先看下目录");
353 let tc = assistant
354 .tool_calls
355 .as_ref()
356 .and_then(|calls| calls.first())
357 .expect("assistant should carry merged tool call");
358 assert_eq!(tc.id, "call_1");
359
360 let tool = &chat_req.messages[1];
361 assert_eq!(tool.role, MessageRole::Tool);
362 assert_eq!(tool.tool_call_id.as_deref(), Some("call_1"));
363 assert_eq!(tool.content.as_text(), "ok");
364 }
365
366 #[test]
367 fn test_non_minimax_keeps_assistant_and_tool_call_split() {
368 let request = make_request(InputItemOrString::Array(vec![
369 InputItem {
370 id: Some("fc_1".to_string()),
371 item_type: InputItemType::FunctionCall,
372 role: None,
373 content: None,
374 name: Some("exec_command".to_string()),
375 arguments: Some(r#"{"cmd":"ls"}"#.to_string()),
376 call_id: Some("call_1".to_string()),
377 output: None,
378 namespace: None,
379 tools: None,
380 },
381 InputItem {
382 id: Some("msg_1".to_string()),
383 item_type: InputItemType::Message,
384 role: Some("assistant".to_string()),
385 content: Some(ResponseContent::String("我先看下目录".to_string())),
386 name: None,
387 arguments: None,
388 call_id: None,
389 output: None,
390 namespace: None,
391 tools: None,
392 },
393 InputItem {
394 id: Some("fco_1".to_string()),
395 item_type: InputItemType::FunctionCallOutput,
396 role: None,
397 content: None,
398 name: Some("exec_command".to_string()),
399 arguments: None,
400 call_id: Some("call_1".to_string()),
401 output: Some("ok".to_string()),
402 namespace: None,
403 tools: None,
404 },
405 ]));
406
407 let provider = GLMProvider;
408 let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
409 assert_eq!(chat_req.messages.len(), 3);
410 assert_eq!(chat_req.messages[0].role, MessageRole::Assistant);
411 assert!(chat_req.messages[0].tool_calls.is_some());
412 assert_eq!(chat_req.messages[1].role, MessageRole::Assistant);
413 assert_eq!(chat_req.messages[2].role, MessageRole::Tool);
414 }
415
416 #[test]
417 fn test_max_output_tokens_maps_to_chat_max_tokens() {
418 let mut request = make_request(InputItemOrString::String("Hello".to_string()));
419 request.max_output_tokens = Some(8);
420
421 let provider = GLMProvider;
422 let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
423 assert_eq!(chat_req.max_tokens, Some(16));
424 }
425
426 #[test]
427 fn test_web_search_preview_tool_degrades_to_function() {
428 let mut request = make_request(InputItemOrString::String("Hello".to_string()));
429 request.tools = vec![Tool {
430 tool_type: ToolType::WebSearchPreview,
431 name: None,
432 description: None,
433 parameters: None,
434 strict: None,
435 extra: HashMap::new(),
436 }];
437
438 let provider = crate::providers::kimi::KimiProvider;
439 let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
440 let tools = chat_req.tools.unwrap_or_default();
441 assert_eq!(tools.len(), 1);
442 assert_eq!(tools[0].tool_type, "function");
443 assert_eq!(tools[0].function.name, "web_search_preview");
444 }
445
446 #[test]
447 fn test_tool_search_output_extracts_tools() {
448 let request = make_request(InputItemOrString::Array(vec![
449 InputItem {
450 id: Some("tsc_1".to_string()),
451 item_type: InputItemType::Message,
452 role: Some("user".to_string()),
453 content: Some(ResponseContent::String("Find tools".to_string())),
454 name: None,
455 arguments: None,
456 call_id: None,
457 output: None,
458 namespace: None,
459 tools: None,
460 },
461 InputItem {
462 id: Some("tso_1".to_string()),
463 item_type: InputItemType::ToolSearchOutput,
464 role: None,
465 content: None,
466 name: None,
467 arguments: None,
468 call_id: Some("tsc_call_1".to_string()),
469 output: None,
470 namespace: None,
471 tools: Some(vec![
472 Tool {
473 tool_type: ToolType::Function,
474 name: Some("search_tool".to_string()),
475 description: Some("A search tool".to_string()),
476 parameters: Some(serde_json::json!({
477 "type": "object",
478 "properties": {}
479 })),
480 strict: Some(false),
481 extra: HashMap::new(),
482 },
483 Tool {
484 tool_type: ToolType::Function,
485 name: Some("calc_tool".to_string()),
486 description: Some("A calculator".to_string()),
487 parameters: Some(serde_json::json!({
488 "type": "object",
489 "properties": {}
490 })),
491 strict: Some(false),
492 extra: HashMap::new(),
493 },
494 ]),
495 },
496 ]));
497
498 let provider = crate::providers::minimax::MiniMaxProvider;
499 let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
500
501 assert_eq!(chat_req.messages.len(), 1);
503 assert_eq!(chat_req.messages[0].role, MessageRole::User);
504
505 let tools = chat_req.tools.unwrap_or_default();
507 assert_eq!(tools.len(), 2);
508 let tool_names: Vec<_> = tools.iter().map(|t| t.function.name.clone()).collect();
509 assert!(tool_names.contains(&"search_tool".to_string()));
510 assert!(tool_names.contains(&"calc_tool".to_string()));
511 }
512
513 #[test]
514 fn test_tool_search_output_merges_with_predefined_tools() {
515 let mut request = make_request(InputItemOrString::Array(vec![
516 InputItem {
517 id: Some("tso_1".to_string()),
518 item_type: InputItemType::ToolSearchOutput,
519 role: None,
520 content: None,
521 name: None,
522 arguments: None,
523 call_id: Some("tsc_call_1".to_string()),
524 output: None,
525 namespace: None,
526 tools: Some(vec![Tool {
527 tool_type: ToolType::Function,
528 name: Some("search_tool".to_string()),
529 description: None,
530 parameters: None,
531 strict: None,
532 extra: HashMap::new(),
533 }]),
534 },
535 ]));
536 request.tools = vec![Tool {
538 tool_type: ToolType::Function,
539 name: Some("search_tool".to_string()),
540 description: Some("Predefined search".to_string()),
541 parameters: None,
542 strict: None,
543 extra: HashMap::new(),
544 }];
545
546 let provider = crate::providers::minimax::MiniMaxProvider;
547 let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
548
549 let tools = chat_req.tools.unwrap_or_default();
550 assert_eq!(tools.len(), 1);
551 assert_eq!(tools[0].function.name, "search_tool");
553 }
554
555 #[test]
556 fn test_input_file_is_not_dropped() {
557 let request = make_request(InputItemOrString::Array(vec![InputItem {
558 id: None,
559 item_type: InputItemType::Message,
560 role: Some("user".to_string()),
561 content: Some(ResponseContent::Array(vec![
562 crate::types::response_api::ContentPart::InputText {
563 text: "Analyze file".to_string(),
564 },
565 crate::types::response_api::ContentPart::InputFile {
566 file_url: Some("https://example.com/file.pdf".to_string()),
567 file_id: None,
568 },
569 ])),
570 name: None,
571 arguments: None,
572 call_id: None,
573 output: None,
574 namespace: None,
575 tools: None,
576 }]));
577
578 let provider = GLMProvider;
579 let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
580 assert!(!chat_req.messages.is_empty());
581 let body = chat_req.messages[0].content.as_text();
582 assert!(body.contains("[input_file]"));
583 assert!(body.contains("file.pdf"));
584 }
585
586 #[test]
587 fn test_text_format_maps_to_chat_response_format() {
588 let mut request = make_request(InputItemOrString::String("Hello".to_string()));
589 request.text = Some(ResponseTextConfig {
590 format: Some(ResponseTextFormat {
591 format_type: "json_schema".to_string(),
592 name: Some("AnswerSchema".to_string()),
593 schema: Some(serde_json::json!({
594 "type": "object",
595 "properties": {
596 "answer": { "type": "string" }
597 }
598 })),
599 strict: Some(true),
600 }),
601 });
602
603 let provider = GLMProvider;
604 let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
605 let response_format = chat_req.response_format.expect("response_format should be mapped");
606 assert_eq!(response_format["type"], "json_schema");
607 assert_eq!(response_format["json_schema"]["name"], "AnswerSchema");
608 assert_eq!(response_format["json_schema"]["strict"], true);
609 }
610
611 #[test]
612 fn test_reasoning_effort_and_parallel_tool_calls_mapped() {
613 let mut request = make_request(InputItemOrString::String("Hello".to_string()));
614 request.reasoning = Some(ResponseReasoning {
615 effort: Some("high".to_string()),
616 summary: None,
617 });
618 request.parallel_tool_calls = Some(false);
619
620 let provider = GLMProvider;
621 let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
622 assert_eq!(chat_req.reasoning_effort.as_deref(), Some("high"));
623 assert_eq!(chat_req.parallel_tool_calls, Some(false));
624 }
625}