Skip to main content

codex_convert_proxy/convert/request/
mod.rs

1//! Request conversion: Responses API → Chat API.
2
3mod 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
48/// Convert a Responses API request to a Chat API request.
49pub 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    // Use model from config if specified, otherwise use provider's model normalization
64    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    // Apply provider-specific transformations.
71    // Tools: pass through even when empty `vec![]` so we don't violate provider expectations on
72    //   schema; serializer skips when None. We filter empty to None to mirror Chat API idiom.
73    // tool_choice: pass through "none" honoring user intent (spec allows "none" mode).
74    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    // Apply min_tokens floor validation (some providers reject max_tokens < MIN_MAX_TOKENS).
104    // Surface as a warn so the silent mutation is observable in logs.
105    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}