Skip to main content

codex_convert_proxy/convert/request/
mod.rs

1//! Request conversion: Responses API → Chat API.
2
3mod context;
4mod messages;
5mod tools;
6
7pub use context::{ToolPriority, ToolSearchContext};
8pub use messages::{convert_input_to_messages, extract_content};
9pub use tools::{convert_tools, convert_tool_choice};
10
11use crate::constants::MIN_MAX_TOKENS;
12use crate::error::ConversionError;
13use crate::providers::Provider;
14use crate::types::chat_api::{ChatRequest, StreamOptions};
15use crate::types::response_api::{ResponseRequest, ResponseTextConfig};
16use tracing::{debug, warn};
17
18fn to_chat_response_format(
19    text: Option<&ResponseTextConfig>,
20) -> Result<Option<serde_json::Value>, ConversionError> {
21    let Some(format) = text.and_then(|t| t.format.as_ref()) else {
22        return Ok(None);
23    };
24    match format.format_type.as_str() {
25        "json_schema" => {
26            let mut json_schema = serde_json::json!({
27                "name": format.name.clone().unwrap_or_else(|| "response_schema".to_string()),
28                "schema": format.schema.clone().unwrap_or_else(|| serde_json::json!({})),
29            });
30            if let Some(strict) = format.strict {
31                json_schema["strict"] = serde_json::json!(strict);
32            }
33            Ok(Some(serde_json::json!({
34                "type": "json_schema",
35                "json_schema": json_schema
36            })))
37        }
38        "json_object" => Ok(Some(serde_json::json!({
39            "type": "json_object"
40        }))),
41        "text" => Ok(Some(serde_json::json!({
42            "type": "text"
43        }))),
44        other => Err(ConversionError::InvalidFormat(format!(
45            "unsupported text.format.type: {other}"
46        ))),
47    }
48}
49
50/// Convert a Responses API request to a Chat API request.
51pub fn response_to_chat(
52    response_req: ResponseRequest,
53    provider: &dyn Provider,
54    model_override: Option<&str>,
55    _tool_priority: ToolPriority,
56) -> Result<ChatRequest, ConversionError> {
57    let enforce_tool_result_adjacency = provider.name() == "minimax";
58    let (messages, extracted_tools) = convert_input_to_messages(
59        response_req.input,
60        response_req.instructions,
61        enforce_tool_result_adjacency,
62    )?;
63
64    // Merge predefined tools with dynamically discovered tools
65    let merged_tools = if extracted_tools.is_empty() {
66        response_req.tools
67    } else if response_req.tools.is_empty() {
68        extracted_tools
69    } else {
70        // Both have tools - use the context's merge strategy
71        use crate::convert::request::context::merge_tools_map;
72        merge_tools_map(&response_req.tools, &extracted_tools)
73    };
74
75    let tools = convert_tools(merged_tools);
76    let tool_choice = convert_tool_choice(response_req.tool_choice);
77
78    // Use model from config if specified, otherwise use provider's model normalization
79    let model = model_override
80        .map(|s| s.to_string())
81        .unwrap_or_else(|| provider.normalize_model(response_req.model));
82
83    let response_format = to_chat_response_format(response_req.text.as_ref())?;
84
85    // Apply provider-specific transformations.
86    // Tools: pass through even when empty `vec![]` so we don't violate provider expectations on
87    //   schema; serializer skips when None. We filter empty to None to mirror Chat API idiom.
88    // tool_choice: pass through "none" honoring user intent (spec allows "none" mode).
89    let mut chat_req = ChatRequest {
90        model,
91        messages,
92        tools: Some(tools).filter(|t| !t.is_empty()),
93        tool_choice: Some(tool_choice),
94        stream: Some(response_req.stream),
95        temperature: response_req.temperature,
96        max_tokens: response_req.max_output_tokens.or(response_req.max_tokens),
97        top_p: response_req.top_p,
98        user: response_req.user,
99        stream_options: if response_req.stream {
100            Some(StreamOptions { include_usage: Some(true) })
101        } else {
102            None
103        },
104        frequency_penalty: None,
105        presence_penalty: None,
106        logit_bias: None,
107        logprobs: None,
108        top_logprobs: None,
109        n: None,
110        stop: None,
111        response_format,
112        reasoning_effort: response_req.reasoning.as_ref().and_then(|r| r.effort.clone()),
113        parallel_tool_calls: response_req.parallel_tool_calls,
114        seed: None,
115        service_tier: None,
116        web_search_options: None,
117        modalities: None,
118        prediction: None,
119        audio: None,
120    };
121
122    // Apply min_tokens floor validation (some providers reject max_tokens < MIN_MAX_TOKENS).
123    // Surface as a warn so the silent mutation is observable in logs.
124    if let Some(max_tokens) = chat_req.max_tokens
125        && max_tokens < MIN_MAX_TOKENS {
126            warn!(
127                "[REQUEST_CONVERT] max_tokens {} below floor {}; raising to floor",
128                max_tokens, MIN_MAX_TOKENS
129            );
130            chat_req.max_tokens = Some(MIN_MAX_TOKENS);
131        }
132
133    provider.transform_request(&mut chat_req);
134
135    debug!(
136        "[REQUEST_CONVERT] converted request: model={}, messages={}, tools={}",
137        chat_req.model,
138        chat_req.messages.len(),
139        chat_req.tools.as_ref().map_or(0, |t| t.len())
140    );
141
142    Ok(chat_req)
143}
144
145#[cfg(test)]
146mod tests {
147    use std::collections::HashMap;
148
149    use super::*;
150    use crate::providers::glm::GLMProvider;
151    use crate::types::chat_api::MessageRole;
152    use crate::types::response_api::{
153        Content as ResponseContent, InputItem, InputItemOrString, InputItemType, ResponseReasoning,
154        ResponseRequest, ResponseTextConfig, ResponseTextFormat,
155        Tool, ToolChoice as ResponseToolChoice, ToolType,
156    };
157
158    fn make_request(input: InputItemOrString) -> ResponseRequest {
159        ResponseRequest {
160            model: "gpt-4o".to_string(),
161            input,
162            instructions: None,
163            tools: vec![],
164            tool_choice: ResponseToolChoice::Auto,
165            stream: false,
166            temperature: None,
167            max_tokens: None,
168            max_output_tokens: None,
169            top_p: None,
170            user: None,
171            reasoning: None,
172            text: None,
173            truncation: None,
174            store: None,
175            metadata: None,
176            previous_response_id: None,
177            parallel_tool_calls: None,
178            background: None,
179        }
180    }
181
182    #[test]
183    fn test_instructions_to_system_message() {
184        let mut request = make_request(InputItemOrString::String("Hello".to_string()));
185        request.instructions = Some("You are a helpful assistant.".to_string());
186
187        let provider = crate::providers::minimax::MiniMaxProvider;
188        let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
189
190        let first = chat_req.messages.first().unwrap();
191        assert_eq!(first.role, MessageRole::System);
192        assert_eq!(first.content.as_text(), "You are a helpful assistant.");
193
194        let second = chat_req.messages.get(1).unwrap();
195        assert_eq!(second.role, MessageRole::User);
196        assert_eq!(second.content.as_text(), "Hello");
197    }
198
199    #[test]
200    fn test_function_call_conversion() {
201        let request = make_request(InputItemOrString::Array(vec![InputItem {
202            id: Some("call_123".to_string()),
203            item_type: InputItemType::FunctionCall,
204            role: None,
205            content: None,
206            name: Some("get_weather".to_string()),
207            arguments: Some(r#"{"city":"Beijing"}"#.to_string()),
208            call_id: None,
209            output: None,
210            namespace: None,
211            tools: None,
212        }]));
213
214        let provider = crate::providers::minimax::MiniMaxProvider;
215        let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
216
217        let msg = chat_req.messages.first().unwrap();
218        assert_eq!(msg.role, MessageRole::Assistant);
219        assert!(msg.tool_calls.is_some());
220
221        let tc = msg.tool_calls.as_ref().unwrap().first().unwrap();
222        assert_eq!(tc.function.name, "get_weather");
223        assert_eq!(tc.function.arguments, r#"{"city":"Beijing"}"#);
224    }
225
226    #[test]
227    fn test_function_call_output() {
228        let request = make_request(InputItemOrString::Array(vec![
229            InputItem {
230                id: Some("call_123".to_string()),
231                item_type: InputItemType::FunctionCall,
232                role: None,
233                content: None,
234                name: Some("get_weather".to_string()),
235                arguments: Some(r#"{"city":"Beijing"}"#.to_string()),
236                call_id: None,
237                output: None,
238                namespace: None,
239                tools: None,
240            },
241            InputItem {
242                id: None,
243                item_type: InputItemType::FunctionCallOutput,
244                role: None,
245                content: None,
246                name: Some("get_weather".to_string()),
247                arguments: None,
248                call_id: Some("call_123".to_string()),
249                output: Some("25 degrees, sunny".to_string()),
250                namespace: None,
251                tools: None,
252            },
253        ]));
254
255        let provider = crate::providers::minimax::MiniMaxProvider;
256        let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
257
258        assert_eq!(chat_req.messages.len(), 2);
259
260        let assistant = &chat_req.messages[0];
261        assert_eq!(assistant.role, MessageRole::Assistant);
262        assert!(assistant.tool_calls.is_some());
263
264        let tool_msg = &chat_req.messages[1];
265        assert_eq!(tool_msg.role, MessageRole::Tool);
266        assert_eq!(tool_msg.tool_call_id.as_deref(), Some("call_123"));
267        assert_eq!(tool_msg.content.as_text(), "25 degrees, sunny");
268    }
269
270    #[test]
271    fn test_orphan_function_call_output_synthesizes_preceding_tool_call() {
272        let request = make_request(InputItemOrString::Array(vec![InputItem {
273            id: None,
274            item_type: InputItemType::FunctionCallOutput,
275            role: None,
276            content: None,
277            name: Some("get_weather".to_string()),
278            arguments: None,
279            call_id: Some("call_orphan".to_string()),
280            output: Some("sunny".to_string()),
281            namespace: None,
282            tools: None,
283        }]));
284
285        let provider = crate::providers::minimax::MiniMaxProvider;
286        let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
287        assert_eq!(chat_req.messages.len(), 2);
288
289        let assistant = &chat_req.messages[0];
290        assert_eq!(assistant.role, MessageRole::Assistant);
291        let tc = assistant
292            .tool_calls
293            .as_ref()
294            .and_then(|calls| calls.first())
295            .expect("synthetic tool call should exist");
296        assert_eq!(tc.id, "call_orphan");
297        assert_eq!(tc.function.name, "get_weather");
298
299        let tool_msg = &chat_req.messages[1];
300        assert_eq!(tool_msg.role, MessageRole::Tool);
301        assert_eq!(tool_msg.tool_call_id.as_deref(), Some("call_orphan"));
302        assert_eq!(tool_msg.content.as_text(), "sunny");
303    }
304
305    #[test]
306    fn test_assistant_message_merges_with_pending_tool_calls() {
307        let request = make_request(InputItemOrString::Array(vec![
308            InputItem {
309                id: Some("fc_1".to_string()),
310                item_type: InputItemType::FunctionCall,
311                role: None,
312                content: None,
313                name: Some("exec_command".to_string()),
314                arguments: Some(r#"{"cmd":"ls"}"#.to_string()),
315                call_id: Some("call_1".to_string()),
316                output: None,
317                namespace: None,
318                tools: None,
319            },
320            InputItem {
321                id: Some("msg_1".to_string()),
322                item_type: InputItemType::Message,
323                role: Some("assistant".to_string()),
324                content: Some(ResponseContent::String("我先看下目录".to_string())),
325                name: None,
326                arguments: None,
327                call_id: None,
328                output: None,
329                namespace: None,
330                tools: None,
331            },
332            InputItem {
333                id: Some("fco_1".to_string()),
334                item_type: InputItemType::FunctionCallOutput,
335                role: None,
336                content: None,
337                name: Some("exec_command".to_string()),
338                arguments: None,
339                call_id: Some("call_1".to_string()),
340                output: Some("ok".to_string()),
341                namespace: None,
342                tools: None,
343            },
344        ]));
345
346        let provider = crate::providers::minimax::MiniMaxProvider;
347        let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
348
349        assert_eq!(chat_req.messages.len(), 2);
350        let assistant = &chat_req.messages[0];
351        assert_eq!(assistant.role, MessageRole::Assistant);
352        assert_eq!(assistant.content.as_text(), "我先看下目录");
353        let tc = assistant
354            .tool_calls
355            .as_ref()
356            .and_then(|calls| calls.first())
357            .expect("assistant should carry merged tool call");
358        assert_eq!(tc.id, "call_1");
359
360        let tool = &chat_req.messages[1];
361        assert_eq!(tool.role, MessageRole::Tool);
362        assert_eq!(tool.tool_call_id.as_deref(), Some("call_1"));
363        assert_eq!(tool.content.as_text(), "ok");
364    }
365
366    #[test]
367    fn test_non_minimax_keeps_assistant_and_tool_call_split() {
368        let request = make_request(InputItemOrString::Array(vec![
369            InputItem {
370                id: Some("fc_1".to_string()),
371                item_type: InputItemType::FunctionCall,
372                role: None,
373                content: None,
374                name: Some("exec_command".to_string()),
375                arguments: Some(r#"{"cmd":"ls"}"#.to_string()),
376                call_id: Some("call_1".to_string()),
377                output: None,
378                namespace: None,
379                tools: None,
380            },
381            InputItem {
382                id: Some("msg_1".to_string()),
383                item_type: InputItemType::Message,
384                role: Some("assistant".to_string()),
385                content: Some(ResponseContent::String("我先看下目录".to_string())),
386                name: None,
387                arguments: None,
388                call_id: None,
389                output: None,
390                namespace: None,
391                tools: None,
392            },
393            InputItem {
394                id: Some("fco_1".to_string()),
395                item_type: InputItemType::FunctionCallOutput,
396                role: None,
397                content: None,
398                name: Some("exec_command".to_string()),
399                arguments: None,
400                call_id: Some("call_1".to_string()),
401                output: Some("ok".to_string()),
402                namespace: None,
403                tools: None,
404            },
405        ]));
406
407        let provider = GLMProvider;
408        let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
409        assert_eq!(chat_req.messages.len(), 3);
410        assert_eq!(chat_req.messages[0].role, MessageRole::Assistant);
411        assert!(chat_req.messages[0].tool_calls.is_some());
412        assert_eq!(chat_req.messages[1].role, MessageRole::Assistant);
413        assert_eq!(chat_req.messages[2].role, MessageRole::Tool);
414    }
415
416    #[test]
417    fn test_max_output_tokens_maps_to_chat_max_tokens() {
418        let mut request = make_request(InputItemOrString::String("Hello".to_string()));
419        request.max_output_tokens = Some(8);
420
421        let provider = GLMProvider;
422        let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
423        assert_eq!(chat_req.max_tokens, Some(16));
424    }
425
426    #[test]
427    fn test_web_search_preview_tool_degrades_to_function() {
428        let mut request = make_request(InputItemOrString::String("Hello".to_string()));
429        request.tools = vec![Tool {
430            tool_type: ToolType::WebSearchPreview,
431            name: None,
432            description: None,
433            parameters: None,
434            strict: None,
435            extra: HashMap::new(),
436        }];
437
438        let provider = crate::providers::kimi::KimiProvider;
439        let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
440        let tools = chat_req.tools.unwrap_or_default();
441        assert_eq!(tools.len(), 1);
442        assert_eq!(tools[0].tool_type, "function");
443        assert_eq!(tools[0].function.name, "web_search_preview");
444    }
445
446    #[test]
447    fn test_tool_search_output_extracts_tools() {
448        let request = make_request(InputItemOrString::Array(vec![
449            InputItem {
450                id: Some("tsc_1".to_string()),
451                item_type: InputItemType::Message,
452                role: Some("user".to_string()),
453                content: Some(ResponseContent::String("Find tools".to_string())),
454                name: None,
455                arguments: None,
456                call_id: None,
457                output: None,
458                namespace: None,
459                tools: None,
460            },
461            InputItem {
462                id: Some("tso_1".to_string()),
463                item_type: InputItemType::ToolSearchOutput,
464                role: None,
465                content: None,
466                name: None,
467                arguments: None,
468                call_id: Some("tsc_call_1".to_string()),
469                output: None,
470                namespace: None,
471                tools: Some(vec![
472                    Tool {
473                        tool_type: ToolType::Function,
474                        name: Some("search_tool".to_string()),
475                        description: Some("A search tool".to_string()),
476                        parameters: Some(serde_json::json!({
477                            "type": "object",
478                            "properties": {}
479                        })),
480                        strict: Some(false),
481                        extra: HashMap::new(),
482                    },
483                    Tool {
484                        tool_type: ToolType::Function,
485                        name: Some("calc_tool".to_string()),
486                        description: Some("A calculator".to_string()),
487                        parameters: Some(serde_json::json!({
488                            "type": "object",
489                            "properties": {}
490                        })),
491                        strict: Some(false),
492                        extra: HashMap::new(),
493                    },
494                ]),
495            },
496        ]));
497
498        let provider = crate::providers::minimax::MiniMaxProvider;
499        let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
500
501        // Should have 2 messages: user message + tool_search_output (no message emitted for tool_search)
502        assert_eq!(chat_req.messages.len(), 1);
503        assert_eq!(chat_req.messages[0].role, MessageRole::User);
504
505        // Tools should be extracted from tool_search_output
506        let tools = chat_req.tools.unwrap_or_default();
507        assert_eq!(tools.len(), 2);
508        let tool_names: Vec<_> = tools.iter().map(|t| t.function.name.clone()).collect();
509        assert!(tool_names.contains(&"search_tool".to_string()));
510        assert!(tool_names.contains(&"calc_tool".to_string()));
511    }
512
513    #[test]
514    fn test_tool_search_output_merges_with_predefined_tools() {
515        let mut request = make_request(InputItemOrString::Array(vec![
516            InputItem {
517                id: Some("tso_1".to_string()),
518                item_type: InputItemType::ToolSearchOutput,
519                role: None,
520                content: None,
521                name: None,
522                arguments: None,
523                call_id: Some("tsc_call_1".to_string()),
524                output: None,
525                namespace: None,
526                tools: Some(vec![Tool {
527                    tool_type: ToolType::Function,
528                    name: Some("search_tool".to_string()),
529                    description: None,
530                    parameters: None,
531                    strict: None,
532                    extra: HashMap::new(),
533                }]),
534            },
535        ]));
536        // Predefined tool with same name - searched tool should override
537        request.tools = vec![Tool {
538            tool_type: ToolType::Function,
539            name: Some("search_tool".to_string()),
540            description: Some("Predefined search".to_string()),
541            parameters: None,
542            strict: None,
543            extra: HashMap::new(),
544        }];
545
546        let provider = crate::providers::minimax::MiniMaxProvider;
547        let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
548
549        let tools = chat_req.tools.unwrap_or_default();
550        assert_eq!(tools.len(), 1);
551        // Searched tool should override predefined (merge_tools_map semantics)
552        assert_eq!(tools[0].function.name, "search_tool");
553    }
554
555    #[test]
556    fn test_input_file_is_not_dropped() {
557        let request = make_request(InputItemOrString::Array(vec![InputItem {
558            id: None,
559            item_type: InputItemType::Message,
560            role: Some("user".to_string()),
561            content: Some(ResponseContent::Array(vec![
562                crate::types::response_api::ContentPart::InputText {
563                    text: "Analyze file".to_string(),
564                },
565                crate::types::response_api::ContentPart::InputFile {
566                    file_url: Some("https://example.com/file.pdf".to_string()),
567                    file_id: None,
568                },
569            ])),
570            name: None,
571            arguments: None,
572            call_id: None,
573            output: None,
574            namespace: None,
575            tools: None,
576        }]));
577
578        let provider = GLMProvider;
579        let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
580        assert!(!chat_req.messages.is_empty());
581        let body = chat_req.messages[0].content.as_text();
582        assert!(body.contains("[input_file]"));
583        assert!(body.contains("file.pdf"));
584    }
585
586    #[test]
587    fn test_text_format_maps_to_chat_response_format() {
588        let mut request = make_request(InputItemOrString::String("Hello".to_string()));
589        request.text = Some(ResponseTextConfig {
590            format: Some(ResponseTextFormat {
591                format_type: "json_schema".to_string(),
592                name: Some("AnswerSchema".to_string()),
593                schema: Some(serde_json::json!({
594                    "type": "object",
595                    "properties": {
596                        "answer": { "type": "string" }
597                    }
598                })),
599                strict: Some(true),
600            }),
601        });
602
603        let provider = GLMProvider;
604        let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
605        let response_format = chat_req.response_format.expect("response_format should be mapped");
606        assert_eq!(response_format["type"], "json_schema");
607        assert_eq!(response_format["json_schema"]["name"], "AnswerSchema");
608        assert_eq!(response_format["json_schema"]["strict"], true);
609    }
610
611    #[test]
612    fn test_reasoning_effort_and_parallel_tool_calls_mapped() {
613        let mut request = make_request(InputItemOrString::String("Hello".to_string()));
614        request.reasoning = Some(ResponseReasoning {
615            effort: Some("high".to_string()),
616            summary: None,
617        });
618        request.parallel_tool_calls = Some(false);
619
620        let provider = GLMProvider;
621        let chat_req = response_to_chat(request, &provider, None, ToolPriority::Merge).unwrap();
622        assert_eq!(chat_req.reasoning_effort.as_deref(), Some("high"));
623        assert_eq!(chat_req.parallel_tool_calls, Some(false));
624    }
625}