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