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