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        }
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}