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 }
146 }
147
148 #[test]
149 fn test_instructions_to_system_message() {
150 let mut request = make_request(InputItemOrString::String("Hello".to_string()));
151 request.instructions = Some("You are a helpful assistant.".to_string());
152
153 let provider = crate::providers::minimax::MiniMaxProvider;
154 let chat_req = response_to_chat(request, &provider, None).unwrap();
155
156 let first = chat_req.messages.first().unwrap();
157 assert_eq!(first.role, MessageRole::System);
158 assert_eq!(first.content.as_text(), "You are a helpful assistant.");
159
160 let second = chat_req.messages.get(1).unwrap();
161 assert_eq!(second.role, MessageRole::User);
162 assert_eq!(second.content.as_text(), "Hello");
163 }
164
165 #[test]
166 fn test_function_call_conversion() {
167 let request = make_request(InputItemOrString::Array(vec![InputItem {
168 id: Some("call_123".to_string()),
169 item_type: InputItemType::FunctionCall,
170 role: None,
171 content: None,
172 name: Some("get_weather".to_string()),
173 arguments: Some(r#"{"city":"Beijing"}"#.to_string()),
174 call_id: None,
175 output: None,
176 }]));
177
178 let provider = crate::providers::minimax::MiniMaxProvider;
179 let chat_req = response_to_chat(request, &provider, None).unwrap();
180
181 let msg = chat_req.messages.first().unwrap();
182 assert_eq!(msg.role, MessageRole::Assistant);
183 assert!(msg.tool_calls.is_some());
184
185 let tc = msg.tool_calls.as_ref().unwrap().first().unwrap();
186 assert_eq!(tc.function.name, "get_weather");
187 assert_eq!(tc.function.arguments, r#"{"city":"Beijing"}"#);
188 }
189
190 #[test]
191 fn test_function_call_output() {
192 let request = make_request(InputItemOrString::Array(vec![
193 InputItem {
194 id: Some("call_123".to_string()),
195 item_type: InputItemType::FunctionCall,
196 role: None,
197 content: None,
198 name: Some("get_weather".to_string()),
199 arguments: Some(r#"{"city":"Beijing"}"#.to_string()),
200 call_id: None,
201 output: None,
202 },
203 InputItem {
204 id: None,
205 item_type: InputItemType::FunctionCallOutput,
206 role: None,
207 content: None,
208 name: Some("get_weather".to_string()),
209 arguments: None,
210 call_id: Some("call_123".to_string()),
211 output: Some("25 degrees, sunny".to_string()),
212 },
213 ]));
214
215 let provider = crate::providers::minimax::MiniMaxProvider;
216 let chat_req = response_to_chat(request, &provider, None).unwrap();
217
218 assert_eq!(chat_req.messages.len(), 2);
219
220 let assistant = &chat_req.messages[0];
221 assert_eq!(assistant.role, MessageRole::Assistant);
222 assert!(assistant.tool_calls.is_some());
223
224 let tool_msg = &chat_req.messages[1];
225 assert_eq!(tool_msg.role, MessageRole::Tool);
226 assert_eq!(tool_msg.tool_call_id.as_deref(), Some("call_123"));
227 assert_eq!(tool_msg.content.as_text(), "25 degrees, sunny");
228 }
229
230 #[test]
231 fn test_orphan_function_call_output_synthesizes_preceding_tool_call() {
232 let request = make_request(InputItemOrString::Array(vec![InputItem {
233 id: None,
234 item_type: InputItemType::FunctionCallOutput,
235 role: None,
236 content: None,
237 name: Some("get_weather".to_string()),
238 arguments: None,
239 call_id: Some("call_orphan".to_string()),
240 output: Some("sunny".to_string()),
241 }]));
242
243 let provider = crate::providers::minimax::MiniMaxProvider;
244 let chat_req = response_to_chat(request, &provider, None).unwrap();
245 assert_eq!(chat_req.messages.len(), 2);
246
247 let assistant = &chat_req.messages[0];
248 assert_eq!(assistant.role, MessageRole::Assistant);
249 let tc = assistant
250 .tool_calls
251 .as_ref()
252 .and_then(|calls| calls.first())
253 .expect("synthetic tool call should exist");
254 assert_eq!(tc.id, "call_orphan");
255 assert_eq!(tc.function.name, "get_weather");
256
257 let tool_msg = &chat_req.messages[1];
258 assert_eq!(tool_msg.role, MessageRole::Tool);
259 assert_eq!(tool_msg.tool_call_id.as_deref(), Some("call_orphan"));
260 assert_eq!(tool_msg.content.as_text(), "sunny");
261 }
262
263 #[test]
264 fn test_assistant_message_merges_with_pending_tool_calls() {
265 let request = make_request(InputItemOrString::Array(vec![
266 InputItem {
267 id: Some("fc_1".to_string()),
268 item_type: InputItemType::FunctionCall,
269 role: None,
270 content: None,
271 name: Some("exec_command".to_string()),
272 arguments: Some(r#"{"cmd":"ls"}"#.to_string()),
273 call_id: Some("call_1".to_string()),
274 output: None,
275 },
276 InputItem {
277 id: Some("msg_1".to_string()),
278 item_type: InputItemType::Message,
279 role: Some("assistant".to_string()),
280 content: Some(ResponseContent::String("我先看下目录".to_string())),
281 name: None,
282 arguments: None,
283 call_id: None,
284 output: None,
285 },
286 InputItem {
287 id: Some("fco_1".to_string()),
288 item_type: InputItemType::FunctionCallOutput,
289 role: None,
290 content: None,
291 name: Some("exec_command".to_string()),
292 arguments: None,
293 call_id: Some("call_1".to_string()),
294 output: Some("ok".to_string()),
295 },
296 ]));
297
298 let provider = crate::providers::minimax::MiniMaxProvider;
299 let chat_req = response_to_chat(request, &provider, None).unwrap();
300
301 assert_eq!(chat_req.messages.len(), 2);
302 let assistant = &chat_req.messages[0];
303 assert_eq!(assistant.role, MessageRole::Assistant);
304 assert_eq!(assistant.content.as_text(), "我先看下目录");
305 let tc = assistant
306 .tool_calls
307 .as_ref()
308 .and_then(|calls| calls.first())
309 .expect("assistant should carry merged tool call");
310 assert_eq!(tc.id, "call_1");
311
312 let tool = &chat_req.messages[1];
313 assert_eq!(tool.role, MessageRole::Tool);
314 assert_eq!(tool.tool_call_id.as_deref(), Some("call_1"));
315 assert_eq!(tool.content.as_text(), "ok");
316 }
317
318 #[test]
319 fn test_non_minimax_keeps_assistant_and_tool_call_split() {
320 let request = make_request(InputItemOrString::Array(vec![
321 InputItem {
322 id: Some("fc_1".to_string()),
323 item_type: InputItemType::FunctionCall,
324 role: None,
325 content: None,
326 name: Some("exec_command".to_string()),
327 arguments: Some(r#"{"cmd":"ls"}"#.to_string()),
328 call_id: Some("call_1".to_string()),
329 output: None,
330 },
331 InputItem {
332 id: Some("msg_1".to_string()),
333 item_type: InputItemType::Message,
334 role: Some("assistant".to_string()),
335 content: Some(ResponseContent::String("我先看下目录".to_string())),
336 name: None,
337 arguments: None,
338 call_id: None,
339 output: None,
340 },
341 InputItem {
342 id: Some("fco_1".to_string()),
343 item_type: InputItemType::FunctionCallOutput,
344 role: None,
345 content: None,
346 name: Some("exec_command".to_string()),
347 arguments: None,
348 call_id: Some("call_1".to_string()),
349 output: Some("ok".to_string()),
350 },
351 ]));
352
353 let provider = GLMProvider;
354 let chat_req = response_to_chat(request, &provider, None).unwrap();
355 assert_eq!(chat_req.messages.len(), 3);
356 assert_eq!(chat_req.messages[0].role, MessageRole::Assistant);
357 assert!(chat_req.messages[0].tool_calls.is_some());
358 assert_eq!(chat_req.messages[1].role, MessageRole::Assistant);
359 assert_eq!(chat_req.messages[2].role, MessageRole::Tool);
360 }
361
362 #[test]
363 fn test_max_output_tokens_maps_to_chat_max_tokens() {
364 let mut request = make_request(InputItemOrString::String("Hello".to_string()));
365 request.max_output_tokens = Some(8);
366
367 let provider = GLMProvider;
368 let chat_req = response_to_chat(request, &provider, None).unwrap();
369 assert_eq!(chat_req.max_tokens, Some(16));
370 }
371
372 #[test]
373 fn test_web_search_preview_tool_degrades_to_function() {
374 let mut request = make_request(InputItemOrString::String("Hello".to_string()));
375 request.tools = vec![Tool {
376 tool_type: ToolType::WebSearchPreview,
377 name: None,
378 description: None,
379 parameters: None,
380 strict: None,
381 extra: HashMap::new(),
382 }];
383
384 let provider = crate::providers::kimi::KimiProvider;
385 let chat_req = response_to_chat(request, &provider, None).unwrap();
386 let tools = chat_req.tools.unwrap_or_default();
387 assert_eq!(tools.len(), 1);
388 assert_eq!(tools[0].tool_type, "function");
389 assert_eq!(tools[0].function.name, "web_search_preview");
390 }
391
392 #[test]
393 fn test_input_file_is_not_dropped() {
394 let request = make_request(InputItemOrString::Array(vec![InputItem {
395 id: None,
396 item_type: InputItemType::Message,
397 role: Some("user".to_string()),
398 content: Some(ResponseContent::Array(vec![
399 crate::types::response_api::ContentPart::InputText {
400 text: "Analyze file".to_string(),
401 },
402 crate::types::response_api::ContentPart::InputFile {
403 file_url: Some("https://example.com/file.pdf".to_string()),
404 file_id: None,
405 },
406 ])),
407 name: None,
408 arguments: None,
409 call_id: None,
410 output: None,
411 }]));
412
413 let provider = GLMProvider;
414 let chat_req = response_to_chat(request, &provider, None).unwrap();
415 assert!(!chat_req.messages.is_empty());
416 let body = chat_req.messages[0].content.as_text();
417 assert!(body.contains("[input_file]"));
418 assert!(body.contains("file.pdf"));
419 }
420
421 #[test]
422 fn test_text_format_maps_to_chat_response_format() {
423 let mut request = make_request(InputItemOrString::String("Hello".to_string()));
424 request.text = Some(ResponseTextConfig {
425 format: Some(ResponseTextFormat {
426 format_type: "json_schema".to_string(),
427 name: Some("AnswerSchema".to_string()),
428 schema: Some(serde_json::json!({
429 "type": "object",
430 "properties": {
431 "answer": { "type": "string" }
432 }
433 })),
434 strict: Some(true),
435 }),
436 });
437
438 let provider = GLMProvider;
439 let chat_req = response_to_chat(request, &provider, None).unwrap();
440 let response_format = chat_req.response_format.expect("response_format should be mapped");
441 assert_eq!(response_format["type"], "json_schema");
442 assert_eq!(response_format["json_schema"]["name"], "AnswerSchema");
443 assert_eq!(response_format["json_schema"]["strict"], true);
444 }
445
446 #[test]
447 fn test_reasoning_effort_and_parallel_tool_calls_mapped() {
448 let mut request = make_request(InputItemOrString::String("Hello".to_string()));
449 request.reasoning = Some(ResponseReasoning {
450 effort: Some("high".to_string()),
451 summary: None,
452 });
453 request.parallel_tool_calls = Some(false);
454
455 let provider = GLMProvider;
456 let chat_req = response_to_chat(request, &provider, None).unwrap();
457 assert_eq!(chat_req.reasoning_effort.as_deref(), Some("high"));
458 assert_eq!(chat_req.parallel_tool_calls, Some(false));
459 }
460}