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, 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
44/// Convert a Responses API request to a Chat API request.
45pub 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    // Use model from config if specified, otherwise use provider's model normalization
60    let model = model_override
61        .map(|s| s.to_string())
62        .unwrap_or_else(|| provider.normalize_model(response_req.model));
63
64    // Apply provider-specific transformations
65    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    // Apply min_tokens floor validation (some providers reject max_tokens < MIN_MAX_TOKENS)
95    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}