1mod messages;
4mod tools;
5
6pub use messages::{convert_input_to_messages, extract_content};
7pub use tools::{convert_tools, convert_tool_choice, is_tool_choice_none};
8
9use crate::constants::MIN_MAX_TOKENS;
10use crate::error::ConversionError;
11use crate::providers::Provider;
12use crate::types::chat_api::{ChatRequest, StreamOptions};
13use crate::types::response_api::{ResponseRequest, ResponseTextConfig};
14use tracing::debug;
15
16fn to_chat_response_format(text: Option<&ResponseTextConfig>) -> Option<serde_json::Value> {
17 let format = text.and_then(|t| t.format.as_ref())?;
18 match format.format_type.as_str() {
19 "json_schema" => {
20 let mut json_schema = serde_json::json!({
21 "name": format.name.clone().unwrap_or_else(|| "response_schema".to_string()),
22 "schema": format.schema.clone().unwrap_or_else(|| serde_json::json!({})),
23 });
24 if let Some(strict) = format.strict {
25 json_schema["strict"] = serde_json::json!(strict);
26 }
27 Some(serde_json::json!({
28 "type": "json_schema",
29 "json_schema": json_schema
30 }))
31 }
32 "json_object" => Some(serde_json::json!({
33 "type": "json_object"
34 })),
35 "text" => Some(serde_json::json!({
36 "type": "text"
37 })),
38 other => Some(serde_json::json!({
39 "type": other
40 })),
41 }
42}
43
44pub fn response_to_chat(
46 response_req: ResponseRequest,
47 provider: &dyn Provider,
48 model_override: Option<&str>,
49) -> Result<ChatRequest, ConversionError> {
50 let enforce_tool_result_adjacency = provider.name() == "minimax";
51 let messages = convert_input_to_messages(
52 response_req.input,
53 response_req.instructions,
54 enforce_tool_result_adjacency,
55 )?;
56 let tools = convert_tools(response_req.tools);
57 let tool_choice = convert_tool_choice(response_req.tool_choice);
58
59 let model = model_override
61 .map(|s| s.to_string())
62 .unwrap_or_else(|| provider.normalize_model(response_req.model));
63
64 let mut chat_req = ChatRequest {
66 model,
67 messages,
68 tools: Some(tools).filter(|t| !t.is_empty()),
69 tool_choice: Some(tool_choice).filter(|tc| !is_tool_choice_none(tc)),
70 stream: Some(response_req.stream),
71 temperature: response_req.temperature,
72 max_tokens: response_req.max_output_tokens.or(response_req.max_tokens),
73 top_p: response_req.top_p,
74 user: response_req.user,
75 stream_options: if response_req.stream {
76 Some(StreamOptions { include_usage: Some(true) })
77 } else {
78 None
79 },
80 frequency_penalty: None,
81 presence_penalty: None,
82 logit_bias: None,
83 logprobs: None,
84 top_logprobs: None,
85 n: None,
86 stop: None,
87 response_format: to_chat_response_format(response_req.text.as_ref()),
88 reasoning_effort: response_req.reasoning.as_ref().and_then(|r| r.effort.clone()),
89 parallel_tool_calls: response_req.parallel_tool_calls,
90 seed: None,
91 service_tier: None,
92 };
93
94 if let Some(max_tokens) = chat_req.max_tokens
96 && max_tokens < MIN_MAX_TOKENS {
97 chat_req.max_tokens = Some(MIN_MAX_TOKENS);
98 }
99
100 provider.transform_request(&mut chat_req);
101
102 debug!(
103 "[REQUEST_CONVERT] converted request: model={}, messages={}, tools={}",
104 chat_req.model,
105 chat_req.messages.len(),
106 chat_req.tools.as_ref().map_or(0, |t| t.len())
107 );
108
109 Ok(chat_req)
110}
111
112#[cfg(test)]
113mod tests {
114 use std::collections::HashMap;
115
116 use super::*;
117 use crate::providers::glm::GLMProvider;
118 use crate::types::chat_api::MessageRole;
119 use crate::types::response_api::{
120 Content as ResponseContent, InputItem, InputItemOrString, InputItemType, ResponseReasoning,
121 ResponseRequest, ResponseTextConfig, ResponseTextFormat,
122 Tool, ToolChoice as ResponseToolChoice, ToolType,
123 };
124
125 fn make_request(input: InputItemOrString) -> ResponseRequest {
126 ResponseRequest {
127 model: "gpt-4o".to_string(),
128 input,
129 instructions: None,
130 tools: vec![],
131 tool_choice: ResponseToolChoice::Auto,
132 stream: false,
133 temperature: None,
134 max_tokens: None,
135 max_output_tokens: None,
136 top_p: None,
137 user: None,
138 reasoning: None,
139 text: None,
140 truncation: None,
141 store: None,
142 metadata: None,
143 previous_response_id: None,
144 parallel_tool_calls: None,
145 background: None,
146 }
147 }
148
149 #[test]
150 fn test_instructions_to_system_message() {
151 let mut request = make_request(InputItemOrString::String("Hello".to_string()));
152 request.instructions = Some("You are a helpful assistant.".to_string());
153
154 let provider = crate::providers::minimax::MiniMaxProvider;
155 let chat_req = response_to_chat(request, &provider, None).unwrap();
156
157 let first = chat_req.messages.first().unwrap();
158 assert_eq!(first.role, MessageRole::System);
159 assert_eq!(first.content.as_text(), "You are a helpful assistant.");
160
161 let second = chat_req.messages.get(1).unwrap();
162 assert_eq!(second.role, MessageRole::User);
163 assert_eq!(second.content.as_text(), "Hello");
164 }
165
166 #[test]
167 fn test_function_call_conversion() {
168 let request = make_request(InputItemOrString::Array(vec![InputItem {
169 id: Some("call_123".to_string()),
170 item_type: InputItemType::FunctionCall,
171 role: None,
172 content: None,
173 name: Some("get_weather".to_string()),
174 arguments: Some(r#"{"city":"Beijing"}"#.to_string()),
175 call_id: None,
176 output: None,
177 namespace: None,
178 }]));
179
180 let provider = crate::providers::minimax::MiniMaxProvider;
181 let chat_req = response_to_chat(request, &provider, None).unwrap();
182
183 let msg = chat_req.messages.first().unwrap();
184 assert_eq!(msg.role, MessageRole::Assistant);
185 assert!(msg.tool_calls.is_some());
186
187 let tc = msg.tool_calls.as_ref().unwrap().first().unwrap();
188 assert_eq!(tc.function.name, "get_weather");
189 assert_eq!(tc.function.arguments, r#"{"city":"Beijing"}"#);
190 }
191
192 #[test]
193 fn test_function_call_output() {
194 let request = make_request(InputItemOrString::Array(vec![
195 InputItem {
196 id: Some("call_123".to_string()),
197 item_type: InputItemType::FunctionCall,
198 role: None,
199 content: None,
200 name: Some("get_weather".to_string()),
201 arguments: Some(r#"{"city":"Beijing"}"#.to_string()),
202 call_id: None,
203 output: None,
204 namespace: None,
205 },
206 InputItem {
207 id: None,
208 item_type: InputItemType::FunctionCallOutput,
209 role: None,
210 content: None,
211 name: Some("get_weather".to_string()),
212 arguments: None,
213 call_id: Some("call_123".to_string()),
214 output: Some("25 degrees, sunny".to_string()),
215 namespace: None,
216 },
217 ]));
218
219 let provider = crate::providers::minimax::MiniMaxProvider;
220 let chat_req = response_to_chat(request, &provider, None).unwrap();
221
222 assert_eq!(chat_req.messages.len(), 2);
223
224 let assistant = &chat_req.messages[0];
225 assert_eq!(assistant.role, MessageRole::Assistant);
226 assert!(assistant.tool_calls.is_some());
227
228 let tool_msg = &chat_req.messages[1];
229 assert_eq!(tool_msg.role, MessageRole::Tool);
230 assert_eq!(tool_msg.tool_call_id.as_deref(), Some("call_123"));
231 assert_eq!(tool_msg.content.as_text(), "25 degrees, sunny");
232 }
233
234 #[test]
235 fn test_orphan_function_call_output_synthesizes_preceding_tool_call() {
236 let request = make_request(InputItemOrString::Array(vec![InputItem {
237 id: None,
238 item_type: InputItemType::FunctionCallOutput,
239 role: None,
240 content: None,
241 name: Some("get_weather".to_string()),
242 arguments: None,
243 call_id: Some("call_orphan".to_string()),
244 output: Some("sunny".to_string()),
245 namespace: None,
246 }]));
247
248 let provider = crate::providers::minimax::MiniMaxProvider;
249 let chat_req = response_to_chat(request, &provider, None).unwrap();
250 assert_eq!(chat_req.messages.len(), 2);
251
252 let assistant = &chat_req.messages[0];
253 assert_eq!(assistant.role, MessageRole::Assistant);
254 let tc = assistant
255 .tool_calls
256 .as_ref()
257 .and_then(|calls| calls.first())
258 .expect("synthetic tool call should exist");
259 assert_eq!(tc.id, "call_orphan");
260 assert_eq!(tc.function.name, "get_weather");
261
262 let tool_msg = &chat_req.messages[1];
263 assert_eq!(tool_msg.role, MessageRole::Tool);
264 assert_eq!(tool_msg.tool_call_id.as_deref(), Some("call_orphan"));
265 assert_eq!(tool_msg.content.as_text(), "sunny");
266 }
267
268 #[test]
269 fn test_assistant_message_merges_with_pending_tool_calls() {
270 let request = make_request(InputItemOrString::Array(vec![
271 InputItem {
272 id: Some("fc_1".to_string()),
273 item_type: InputItemType::FunctionCall,
274 role: None,
275 content: None,
276 name: Some("exec_command".to_string()),
277 arguments: Some(r#"{"cmd":"ls"}"#.to_string()),
278 call_id: Some("call_1".to_string()),
279 output: None,
280 namespace: None,
281 },
282 InputItem {
283 id: Some("msg_1".to_string()),
284 item_type: InputItemType::Message,
285 role: Some("assistant".to_string()),
286 content: Some(ResponseContent::String("我先看下目录".to_string())),
287 name: None,
288 arguments: None,
289 call_id: None,
290 output: None,
291 namespace: None,
292 },
293 InputItem {
294 id: Some("fco_1".to_string()),
295 item_type: InputItemType::FunctionCallOutput,
296 role: None,
297 content: None,
298 name: Some("exec_command".to_string()),
299 arguments: None,
300 call_id: Some("call_1".to_string()),
301 output: Some("ok".to_string()),
302 namespace: None,
303 },
304 ]));
305
306 let provider = crate::providers::minimax::MiniMaxProvider;
307 let chat_req = response_to_chat(request, &provider, None).unwrap();
308
309 assert_eq!(chat_req.messages.len(), 2);
310 let assistant = &chat_req.messages[0];
311 assert_eq!(assistant.role, MessageRole::Assistant);
312 assert_eq!(assistant.content.as_text(), "我先看下目录");
313 let tc = assistant
314 .tool_calls
315 .as_ref()
316 .and_then(|calls| calls.first())
317 .expect("assistant should carry merged tool call");
318 assert_eq!(tc.id, "call_1");
319
320 let tool = &chat_req.messages[1];
321 assert_eq!(tool.role, MessageRole::Tool);
322 assert_eq!(tool.tool_call_id.as_deref(), Some("call_1"));
323 assert_eq!(tool.content.as_text(), "ok");
324 }
325
326 #[test]
327 fn test_non_minimax_keeps_assistant_and_tool_call_split() {
328 let request = make_request(InputItemOrString::Array(vec![
329 InputItem {
330 id: Some("fc_1".to_string()),
331 item_type: InputItemType::FunctionCall,
332 role: None,
333 content: None,
334 name: Some("exec_command".to_string()),
335 arguments: Some(r#"{"cmd":"ls"}"#.to_string()),
336 call_id: Some("call_1".to_string()),
337 output: None,
338 namespace: None,
339 },
340 InputItem {
341 id: Some("msg_1".to_string()),
342 item_type: InputItemType::Message,
343 role: Some("assistant".to_string()),
344 content: Some(ResponseContent::String("我先看下目录".to_string())),
345 name: None,
346 arguments: None,
347 call_id: None,
348 output: None,
349 namespace: None,
350 },
351 InputItem {
352 id: Some("fco_1".to_string()),
353 item_type: InputItemType::FunctionCallOutput,
354 role: None,
355 content: None,
356 name: Some("exec_command".to_string()),
357 arguments: None,
358 call_id: Some("call_1".to_string()),
359 output: Some("ok".to_string()),
360 namespace: None,
361 },
362 ]));
363
364 let provider = GLMProvider;
365 let chat_req = response_to_chat(request, &provider, None).unwrap();
366 assert_eq!(chat_req.messages.len(), 3);
367 assert_eq!(chat_req.messages[0].role, MessageRole::Assistant);
368 assert!(chat_req.messages[0].tool_calls.is_some());
369 assert_eq!(chat_req.messages[1].role, MessageRole::Assistant);
370 assert_eq!(chat_req.messages[2].role, MessageRole::Tool);
371 }
372
373 #[test]
374 fn test_max_output_tokens_maps_to_chat_max_tokens() {
375 let mut request = make_request(InputItemOrString::String("Hello".to_string()));
376 request.max_output_tokens = Some(8);
377
378 let provider = GLMProvider;
379 let chat_req = response_to_chat(request, &provider, None).unwrap();
380 assert_eq!(chat_req.max_tokens, Some(16));
381 }
382
383 #[test]
384 fn test_web_search_preview_tool_degrades_to_function() {
385 let mut request = make_request(InputItemOrString::String("Hello".to_string()));
386 request.tools = vec![Tool {
387 tool_type: ToolType::WebSearchPreview,
388 name: None,
389 description: None,
390 parameters: None,
391 strict: None,
392 extra: HashMap::new(),
393 }];
394
395 let provider = crate::providers::kimi::KimiProvider;
396 let chat_req = response_to_chat(request, &provider, None).unwrap();
397 let tools = chat_req.tools.unwrap_or_default();
398 assert_eq!(tools.len(), 1);
399 assert_eq!(tools[0].tool_type, "function");
400 assert_eq!(tools[0].function.name, "web_search_preview");
401 }
402
403 #[test]
404 fn test_input_file_is_not_dropped() {
405 let request = make_request(InputItemOrString::Array(vec![InputItem {
406 id: None,
407 item_type: InputItemType::Message,
408 role: Some("user".to_string()),
409 content: Some(ResponseContent::Array(vec![
410 crate::types::response_api::ContentPart::InputText {
411 text: "Analyze file".to_string(),
412 },
413 crate::types::response_api::ContentPart::InputFile {
414 file_url: Some("https://example.com/file.pdf".to_string()),
415 file_id: None,
416 },
417 ])),
418 name: None,
419 arguments: None,
420 call_id: None,
421 output: None,
422 namespace: None,
423 }]));
424
425 let provider = GLMProvider;
426 let chat_req = response_to_chat(request, &provider, None).unwrap();
427 assert!(!chat_req.messages.is_empty());
428 let body = chat_req.messages[0].content.as_text();
429 assert!(body.contains("[input_file]"));
430 assert!(body.contains("file.pdf"));
431 }
432
433 #[test]
434 fn test_text_format_maps_to_chat_response_format() {
435 let mut request = make_request(InputItemOrString::String("Hello".to_string()));
436 request.text = Some(ResponseTextConfig {
437 format: Some(ResponseTextFormat {
438 format_type: "json_schema".to_string(),
439 name: Some("AnswerSchema".to_string()),
440 schema: Some(serde_json::json!({
441 "type": "object",
442 "properties": {
443 "answer": { "type": "string" }
444 }
445 })),
446 strict: Some(true),
447 }),
448 });
449
450 let provider = GLMProvider;
451 let chat_req = response_to_chat(request, &provider, None).unwrap();
452 let response_format = chat_req.response_format.expect("response_format should be mapped");
453 assert_eq!(response_format["type"], "json_schema");
454 assert_eq!(response_format["json_schema"]["name"], "AnswerSchema");
455 assert_eq!(response_format["json_schema"]["strict"], true);
456 }
457
458 #[test]
459 fn test_reasoning_effort_and_parallel_tool_calls_mapped() {
460 let mut request = make_request(InputItemOrString::String("Hello".to_string()));
461 request.reasoning = Some(ResponseReasoning {
462 effort: Some("high".to_string()),
463 summary: None,
464 });
465 request.parallel_tool_calls = Some(false);
466
467 let provider = GLMProvider;
468 let chat_req = response_to_chat(request, &provider, None).unwrap();
469 assert_eq!(chat_req.reasoning_effort.as_deref(), Some("high"));
470 assert_eq!(chat_req.parallel_tool_calls, Some(false));
471 }
472}