Skip to main content

codex_convert_proxy/convert/
response.rs

1//! Response conversion: Chat API → Responses API.
2
3use crate::error::ConversionError;
4use crate::types::chat_api::{ChatMessageAnnotation, ChatResponse, Content};
5use crate::types::response_api::{
6    InputTokensDetails, OutputItemType, OutputTokensDetails, ResponseAnnotation, ResponseContentPart, ResponseObject,
7    ResponseOutputItem, ResponseTextConfig, ResponseTextFormat, Usage,
8};
9use crate::convert::streaming::ResponseRequestContext;
10use super::util::{extract_queries_from_arguments, map_tool_name_to_output_type, parse_thought_tags};
11
12/// Convert a Chat API response to a Responses API ResponseObject.
13pub fn chat_to_response(chat_resp: ChatResponse) -> Result<ResponseObject, ConversionError> {
14    chat_to_response_with_context(chat_resp, None)
15}
16
17/// Convert a Chat API response to a Responses API ResponseObject with optional request context.
18pub fn chat_to_response_with_context(
19    chat_resp: ChatResponse,
20    request_context: Option<&ResponseRequestContext>,
21) -> Result<ResponseObject, ConversionError> {
22    let choice = chat_resp
23        .choices
24        .first()
25        .ok_or_else(|| ConversionError::MissingField("choices".to_string()))?;
26    let mapped_annotations = choice
27        .message
28        .annotations
29        .as_ref()
30        .map(|annotations| {
31            annotations
32                .iter()
33                .map(|anno| match anno {
34                    ChatMessageAnnotation::UrlCitation {
35                        start_index,
36                        end_index,
37                        url,
38                        title,
39                    } => ResponseAnnotation::UrlCitation {
40                        start_index: *start_index,
41                        end_index: *end_index,
42                        url: url.clone(),
43                        title: title.clone(),
44                    },
45                    ChatMessageAnnotation::FileCitation {
46                        index,
47                        file_id,
48                        filename,
49                    } => ResponseAnnotation::FileCitation {
50                        index: *index,
51                        file_id: file_id.clone(),
52                        filename: filename.clone(),
53                    },
54                })
55                .collect::<Vec<_>>()
56        })
57        .unwrap_or_default();
58
59
60    let mut outputs = Vec::new();
61    let finish_reason = choice.finish_reason.as_deref().unwrap_or("stop");
62    let (response_status, incomplete_details) = match finish_reason {
63        "length" => (
64            "incomplete".to_string(),
65            Some(serde_json::json!({"reason": "max_output_tokens"})),
66        ),
67        "content_filter" => (
68            "incomplete".to_string(),
69            Some(serde_json::json!({"reason": "content_filter"})),
70        ),
71        _ => ("completed".to_string(), None),
72    };
73
74    let mut message_parts: Vec<ResponseContentPart> = Vec::new();
75
76    // Convert message content (strip thinking tags)
77    if let Some(content) = extract_content(&choice.message.content) {
78        let (actual_content, reasoning) = parse_thought_tags(&content);
79
80        // Add reasoning output if present
81        if let Some(ref reasoning_text) = reasoning
82            && !reasoning_text.is_empty() {
83                outputs.push(ResponseOutputItem {
84                    id: format!("reasoning_{}", chat_resp.id),
85                    item_type: OutputItemType::Reasoning,
86                    status: Some("completed".to_string()),
87                    content: Some(vec![ResponseContentPart::OutputText {
88                        text: reasoning_text.clone(),
89                        annotations: mapped_annotations.clone(),
90                    }]),
91                    role: None,
92                    name: None,
93                    arguments: None,
94                    call_id: None,
95                    queries: None,
96                    results: None,
97                });
98            }
99
100        // Add text output if present (after stripping thinking tags)
101        if !actual_content.is_empty() {
102            message_parts.push(ResponseContentPart::OutputText {
103                text: actual_content,
104                annotations: mapped_annotations.clone(),
105            });
106        }
107    }
108
109    // Convert explicit refusal payload when present.
110    if let Some(refusal) = &choice.message.refusal
111        && !refusal.is_empty()
112    {
113        message_parts.push(ResponseContentPart::Refusal {
114            refusal: refusal.clone(),
115        });
116    }
117
118    if !message_parts.is_empty() {
119        outputs.push(ResponseOutputItem {
120            id: format!("msg_{}", chat_resp.id),
121            item_type: OutputItemType::Message,
122            status: Some("completed".to_string()),
123            content: Some(message_parts),
124            role: Some("assistant".to_string()),
125            name: None,
126            arguments: None,
127            call_id: None,
128            queries: None,
129            results: None,
130        });
131    }
132
133    // Convert tool calls
134    let mut normalized_tool_calls = choice.message.tool_calls.clone().unwrap_or_default();
135    if normalized_tool_calls.is_empty()
136        && let Some(function_call) = &choice.message.function_call
137    {
138        normalized_tool_calls.push(crate::types::chat_api::ToolCall {
139            id: format!("call_{}", chat_resp.id),
140            tool_type: "function".to_string(),
141            function: function_call.clone(),
142        });
143    }
144    for tc in &normalized_tool_calls {
145            let mapped_type = map_tool_name_to_output_type(
146                &tc.function.name,
147                request_context.map(|ctx| &ctx.tools),
148            );
149            let (queries, results) = if mapped_type != OutputItemType::FunctionCall {
150                (extract_queries_from_arguments(&tc.function.arguments), Some(serde_json::Value::Null))
151            } else {
152                (None, None)
153            };
154
155            outputs.push(ResponseOutputItem {
156                id: format!("fc_{}", tc.id),
157                item_type: mapped_type,
158                status: Some("completed".to_string()),
159                content: None,
160                role: None,
161                name: Some(tc.function.name.clone()),
162                arguments: Some(tc.function.arguments.clone()),
163                call_id: Some(tc.id.clone()),
164                queries,
165                results,
166            });
167    }
168
169    let usage = chat_resp.usage.map(|u| Usage {
170        input_tokens: u.prompt_tokens.map(|t| t as i64),
171        input_tokens_details: Some(InputTokensDetails {
172            cached_tokens: u
173                .prompt_tokens_details
174                .as_ref()
175                .and_then(|d| d.cached_tokens)
176                .map(|v| v as i64)
177                .unwrap_or(0),
178        }),
179        output_tokens: u.completion_tokens.map(|t| t as i64),
180        output_tokens_details: Some(OutputTokensDetails {
181            reasoning_tokens: u
182                .completion_tokens_details
183                .as_ref()
184                .and_then(|d| d.reasoning_tokens)
185                .map(|v| v as i64)
186                .unwrap_or(0),
187        }),
188        total_tokens: u.total_tokens.map(|t| t as i64),
189    });
190
191    let default_text = Some(ResponseTextConfig {
192        format: Some(ResponseTextFormat {
193            format_type: "text".to_string(),
194            name: None,
195            schema: None,
196            strict: None,
197        }),
198    });
199
200    Ok(ResponseObject {
201        id: format!("resp_{}", chat_resp.id),
202        object: "response".to_string(),
203        status: response_status,
204        model: chat_resp.model,
205        created_at: chat_resp.created as i64,
206        completed_at: Some(chrono::Utc::now().timestamp()),
207        error: None,
208        incomplete_details,
209        instructions: request_context.and_then(|ctx| ctx.instructions.clone()),
210        max_output_tokens: request_context.and_then(|ctx| ctx.max_output_tokens),
211        max_tool_calls: None,
212        input: None,  // Input not available in non-streaming context
213        output: outputs,
214        parallel_tool_calls: request_context.and_then(|ctx| ctx.parallel_tool_calls),
215        previous_response_id: request_context.and_then(|ctx| ctx.previous_response_id.clone()),
216        reasoning: request_context.and_then(|ctx| ctx.reasoning.clone()),
217        store: request_context.and_then(|ctx| ctx.store),
218        temperature: request_context.and_then(|ctx| ctx.temperature),
219        text: request_context.and_then(|ctx| ctx.text.clone()).or(default_text),
220        tool_choice: request_context.map(|ctx| ctx.tool_choice.clone()),
221        tools: request_context.map(|ctx| ctx.tools.clone()),
222        top_p: request_context.and_then(|ctx| ctx.top_p),
223        truncation: request_context.and_then(|ctx| ctx.truncation.clone()),
224        user: request_context.and_then(|ctx| ctx.user.clone()),
225        metadata: request_context.and_then(|ctx| ctx.metadata.clone()),
226        usage,
227    })
228}
229
230/// Extract text content from a ChatMessage.
231fn extract_content(content: &Content) -> Option<String> {
232    let text = match content {
233        Content::String(s) => {
234            if s.is_empty() {
235                return None;
236            }
237            s.clone()
238        }
239        Content::Array(arr) => {
240            let text: String = arr
241                .iter()
242                .filter_map(|b| b.text.clone())
243                .collect::<Vec<_>>()
244                .join("");
245            if text.is_empty() {
246                return None;
247            }
248            text
249        }
250    };
251    Some(text)
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::types::chat_api::{
258        ChatChoice, ChatMessage, ChatMessageAnnotation, CompletionTokensDetails, Content, MessageRole,
259        PromptTokensDetails,
260    };
261    use crate::types::response_api::{InputItemOrString, ResponseRequest, Tool, ToolChoice, ToolType};
262    use std::collections::HashMap;
263
264    #[test]
265    fn test_basic_response_conversion() {
266        let chat_resp = ChatResponse {
267            id: "chat_123".to_string(),
268            object_name: "chat.completion".to_string(),
269            created: 1234567890,
270            model: "gpt-4o".to_string(),
271            choices: vec![ChatChoice {
272                index: 0,
273                message: ChatMessage {
274                    role: MessageRole::Assistant,
275                    content: Content::String("Hello, how can I help you?".to_string()),
276                    name: None,
277                    annotations: None,
278                    tool_calls: None,
279                    tool_call_id: None,
280                    function_call: None,
281                    refusal: None,
282                },
283                finish_reason: Some("stop".to_string()),
284            }],
285            usage: Some(crate::types::chat_api::ChatUsage {
286                prompt_tokens: Some(10),
287                completion_tokens: Some(20),
288                total_tokens: Some(30),
289                prompt_tokens_details: None,
290                completion_tokens_details: None,
291            }),
292            service_tier: None,
293            system_fingerprint: None,
294        };
295
296        let response = chat_to_response(chat_resp).unwrap();
297
298        assert_eq!(response.status, "completed");
299        assert!(!response.output.is_empty());
300
301        let msg_output = response.output.first().unwrap();
302        assert_eq!(msg_output.item_type, OutputItemType::Message);
303
304        let text = msg_output.content.as_ref().and_then(|c| c.first());
305        match text {
306            Some(ResponseContentPart::OutputText { text, .. }) => {
307                assert_eq!(text, "Hello, how can I help you?");
308            }
309            _ => panic!("Expected output text"),
310        }
311
312        assert!(response.usage.is_some());
313        let usage = response.usage.unwrap();
314        assert_eq!(usage.input_tokens, Some(10));
315        assert_eq!(usage.output_tokens, Some(20));
316    }
317
318    #[test]
319    fn test_annotation_and_usage_details_mapping() {
320        let chat_resp = ChatResponse {
321            id: "chat_anno".to_string(),
322            object_name: "chat.completion".to_string(),
323            created: 1234567890,
324            model: "gpt-4o".to_string(),
325            choices: vec![ChatChoice {
326                index: 0,
327                message: ChatMessage {
328                    role: MessageRole::Assistant,
329                    content: Content::String("参考来源".to_string()),
330                    name: None,
331                    annotations: Some(vec![ChatMessageAnnotation::UrlCitation {
332                        start_index: 0,
333                        end_index: 4,
334                        url: "https://example.com".to_string(),
335                        title: "Example".to_string(),
336                    }]),
337                    tool_calls: None,
338                    tool_call_id: None,
339                    function_call: None,
340                    refusal: None,
341                },
342                finish_reason: Some("stop".to_string()),
343            }],
344            usage: Some(crate::types::chat_api::ChatUsage {
345                prompt_tokens: Some(10),
346                completion_tokens: Some(20),
347                total_tokens: Some(30),
348                prompt_tokens_details: Some(PromptTokensDetails {
349                    cached_tokens: Some(3),
350                }),
351                completion_tokens_details: Some(CompletionTokensDetails {
352                    reasoning_tokens: Some(7),
353                }),
354            }),
355            service_tier: None,
356            system_fingerprint: None,
357        };
358
359        let response = chat_to_response(chat_resp).unwrap();
360        let content = response.output[0].content.as_ref().unwrap();
361        match &content[0] {
362            ResponseContentPart::OutputText { annotations, .. } => {
363                assert!(!annotations.is_empty());
364            }
365            _ => panic!("expected output text"),
366        }
367        let usage = response.usage.unwrap();
368        assert_eq!(
369            usage.input_tokens_details.unwrap().cached_tokens,
370            3
371        );
372        assert_eq!(
373            usage.output_tokens_details.unwrap().reasoning_tokens,
374            7
375        );
376    }
377
378    #[test]
379    fn test_tool_call_conversion() {
380        let chat_resp = ChatResponse {
381            id: "chat_123".to_string(),
382            object_name: "chat.completion".to_string(),
383            created: 1234567890,
384            model: "gpt-4o".to_string(),
385            choices: vec![ChatChoice {
386                index: 0,
387                message: ChatMessage {
388                    role: MessageRole::Assistant,
389                    content: Content::String(String::new()),
390                    name: None,
391                    annotations: None,
392                    tool_calls: Some(vec![crate::types::chat_api::ToolCall {
393                        id: "call_abc".to_string(),
394                        tool_type: "function".to_string(),
395                        function: crate::types::chat_api::FunctionCall {
396                            name: "get_weather".to_string(),
397                            arguments: r#"{"city":"Beijing"}"#.to_string(),
398                        },
399                    }]),
400                    tool_call_id: None,
401                    function_call: None,
402                    refusal: None,
403                },
404                finish_reason: Some("tool_calls".to_string()),
405            }],
406            usage: None,
407            service_tier: None,
408            system_fingerprint: None,
409        };
410
411        let response = chat_to_response(chat_resp).unwrap();
412
413        // Should have function call output
414        let func_output = response
415            .output
416            .iter()
417            .find(|o| o.item_type == OutputItemType::FunctionCall);
418        assert!(func_output.is_some());
419
420        let func = func_output.unwrap();
421        assert_eq!(func.name.as_deref(), Some("get_weather"));
422        assert_eq!(func.arguments.as_deref(), Some(r#"{"city":"Beijing"}"#));
423    }
424
425    #[test]
426    fn test_builtin_tool_call_roundtrip_type_mapping() {
427        let chat_resp = ChatResponse {
428            id: "chat_123".to_string(),
429            object_name: "chat.completion".to_string(),
430            created: 1234567890,
431            model: "gpt-4o".to_string(),
432            choices: vec![ChatChoice {
433                index: 0,
434                message: ChatMessage {
435                    role: MessageRole::Assistant,
436                    content: Content::String(String::new()),
437                    name: None,
438                    annotations: None,
439                    tool_calls: Some(vec![crate::types::chat_api::ToolCall {
440                        id: "call_web".to_string(),
441                        tool_type: "function".to_string(),
442                        function: crate::types::chat_api::FunctionCall {
443                            name: "web_search_preview".to_string(),
444                            arguments: r#"{"query":"news"}"#.to_string(),
445                        },
446                    }]),
447                    tool_call_id: None,
448                    function_call: None,
449                    refusal: None,
450                },
451                finish_reason: Some("tool_calls".to_string()),
452            }],
453            usage: None,
454            service_tier: None,
455            system_fingerprint: None,
456        };
457
458        let req = ResponseRequest {
459            model: "gpt-4o".to_string(),
460            input: InputItemOrString::String("hi".to_string()),
461            instructions: None,
462            tools: vec![Tool {
463                tool_type: ToolType::WebSearchPreview,
464                name: Some("web_search_preview".to_string()),
465                description: None,
466                parameters: None,
467                strict: None,
468                extra: HashMap::new(),
469            }],
470            tool_choice: ToolChoice::Auto,
471            stream: false,
472            temperature: None,
473            max_output_tokens: None,
474            max_tokens: None,
475            top_p: None,
476            user: None,
477            reasoning: None,
478            text: None,
479            truncation: None,
480            store: None,
481            metadata: None,
482            previous_response_id: None,
483            parallel_tool_calls: None,
484        };
485        let ctx = crate::convert::streaming::ResponseRequestContext::from(&req);
486        let response = chat_to_response_with_context(chat_resp, Some(&ctx)).unwrap();
487
488        let web = response
489            .output
490            .iter()
491            .find(|o| o.item_type == OutputItemType::WebSearchCall)
492            .expect("should map to web_search_call");
493        assert_eq!(web.call_id.as_deref(), Some("call_web"));
494    }
495
496    #[test]
497    fn test_parse_thought_tags() {
498        // No thought tags - should return original content
499        let (content, reasoning) = parse_thought_tags("Hello world");
500        assert_eq!(content, "Hello world");
501        assert!(reasoning.is_none());
502
503        // Single thought tag
504        let (content, reasoning) = parse_thought_tags("<thought>I should search</thought>Hello world");
505        assert_eq!(content, "Hello world");
506        assert_eq!(reasoning, Some("I should search".to_string()));
507
508        // Multiple thought tags - reasoning parts are joined with newlines
509        let (content, reasoning) = parse_thought_tags(
510            "<thought>Step 1: analyze</thought>Result1<thought>Step 2: conclude</thought>Final answer"
511        );
512        assert_eq!(content, "Result1Final answer");
513        assert_eq!(reasoning, Some("Step 1: analyze\n\nStep 2: conclude".to_string()));
514
515        // Unclosed thought tag
516        let (content, reasoning) = parse_thought_tags("<thought>unclosed Hello");
517        assert_eq!(content, "<thought>unclosed Hello");
518        assert!(reasoning.is_none());
519
520        // Content before and after thought
521        let (content, reasoning) = parse_thought_tags("Hello<thought>reasoning</thought>World");
522        assert_eq!(content, "HelloWorld");
523        assert_eq!(reasoning, Some("reasoning".to_string()));
524    }
525
526    #[test]
527    fn test_parse_think_tags() {
528        // MiniMax uses <think> tags instead of <thought>
529        let (content, reasoning) = parse_thought_tags("<think>\n分析当前目录\n</think>\n\n让我看看项目");
530        assert_eq!(content, "让我看看项目");
531        assert_eq!(reasoning, Some("\n分析当前目录\n".to_string()));
532
533        // Multiple think tags
534        let (content, reasoning) = parse_thought_tags(
535            "<think>Step 1</think>Result1<think>Step 2</think>Final"
536        );
537        assert_eq!(content, "Result1Final");
538        assert_eq!(reasoning, Some("Step 1\n\nStep 2".to_string()));
539
540        // Mixed tags (shouldn't happen but test robustness)
541        let (content, reasoning) = parse_thought_tags("<thought>A</thought>B<think>C</think>D");
542        assert_eq!(content, "BD");
543        assert_eq!(reasoning, Some("A\n\nC".to_string()));
544
545        // Empty think tag
546        let (content, reasoning) = parse_thought_tags("<think>Hello");
547        assert_eq!(content, "<think>Hello");
548        assert!(reasoning.is_none());
549    }
550
551    #[test]
552    fn test_finish_reason_length_maps_incomplete() {
553        let chat_resp = ChatResponse {
554            id: "chat_len".to_string(),
555            object_name: "chat.completion".to_string(),
556            created: 1234567890,
557            model: "gpt-4o".to_string(),
558            choices: vec![ChatChoice {
559                index: 0,
560                message: ChatMessage {
561                    role: MessageRole::Assistant,
562                    content: Content::String("partial".to_string()),
563                    name: None,
564                    annotations: None,
565                    tool_calls: None,
566                    tool_call_id: None,
567                    function_call: None,
568                    refusal: None,
569                },
570                finish_reason: Some("length".to_string()),
571            }],
572            usage: None,
573            service_tier: None,
574            system_fingerprint: None,
575        };
576        let response = chat_to_response(chat_resp).unwrap();
577        assert_eq!(response.status, "incomplete");
578        assert_eq!(
579            response
580                .incomplete_details
581                .as_ref()
582                .and_then(|v| v.get("reason"))
583                .and_then(|v| v.as_str()),
584            Some("max_output_tokens")
585        );
586    }
587
588    #[test]
589    fn test_legacy_function_call_is_converted() {
590        let chat_resp = ChatResponse {
591            id: "chat_fc".to_string(),
592            object_name: "chat.completion".to_string(),
593            created: 1234567890,
594            model: "gpt-4o".to_string(),
595            choices: vec![ChatChoice {
596                index: 0,
597                message: ChatMessage {
598                    role: MessageRole::Assistant,
599                    content: Content::String(String::new()),
600                    name: None,
601                    annotations: None,
602                    tool_calls: None,
603                    tool_call_id: None,
604                    function_call: Some(crate::types::chat_api::FunctionCall {
605                        name: "get_weather".to_string(),
606                        arguments: r#"{"city":"Beijing"}"#.to_string(),
607                    }),
608                    refusal: None,
609                },
610                finish_reason: Some("function_call".to_string()),
611            }],
612            usage: None,
613            service_tier: None,
614            system_fingerprint: None,
615        };
616        let response = chat_to_response(chat_resp).unwrap();
617        assert!(response
618            .output
619            .iter()
620            .any(|item| item.item_type == OutputItemType::FunctionCall));
621    }
622
623    #[test]
624    fn test_refusal_maps_to_message_refusal_content() {
625        let chat_resp = ChatResponse {
626            id: "chat_refuse".to_string(),
627            object_name: "chat.completion".to_string(),
628            created: 1234567890,
629            model: "gpt-4o".to_string(),
630            choices: vec![ChatChoice {
631                index: 0,
632                message: ChatMessage {
633                    role: MessageRole::Assistant,
634                    content: Content::String(String::new()),
635                    name: None,
636                    annotations: None,
637                    tool_calls: None,
638                    tool_call_id: None,
639                    function_call: None,
640                    refusal: Some("I cannot help with that.".to_string()),
641                },
642                finish_reason: Some("stop".to_string()),
643            }],
644            usage: None,
645            service_tier: None,
646            system_fingerprint: None,
647        };
648        let response = chat_to_response(chat_resp).unwrap();
649        let refusal_msg = response
650            .output
651            .iter()
652            .find(|item| {
653                item.content
654                    .as_ref()
655                    .is_some_and(|parts| parts.iter().any(|p| matches!(p, ResponseContentPart::Refusal { .. })))
656            })
657            .expect("refusal content should exist");
658        assert_eq!(refusal_msg.item_type, OutputItemType::Message);
659        let message_count = response
660            .output
661            .iter()
662            .filter(|item| item.item_type == OutputItemType::Message)
663            .count();
664        assert_eq!(message_count, 1, "refusal must be in same message item");
665        let parts = refusal_msg.content.as_ref().expect("message content should exist");
666        assert!(parts.iter().any(|p| matches!(
667            p,
668            ResponseContentPart::Refusal { refusal } if refusal == "I cannot help with that."
669        )));
670    }
671}