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::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        // Spec default: parallel_tool_calls=true. Honor request context when present.
222        parallel_tool_calls: request_context
223            .and_then(|ctx| ctx.parallel_tool_calls)
224            .unwrap_or(true),
225        previous_response_id: request_context.and_then(|ctx| ctx.previous_response_id.clone()),
226        reasoning: request_context.and_then(|ctx| ctx.reasoning.clone()),
227        store: request_context.and_then(|ctx| ctx.store),
228        temperature: request_context.and_then(|ctx| ctx.temperature),
229        text: request_context.and_then(|ctx| ctx.text.clone()).or(default_text),
230        tool_choice: request_context
231            .map(|ctx| ctx.tool_choice.clone())
232            .unwrap_or_default(),
233        tools: request_context
234            .map(|ctx| ctx.tools.clone())
235            .unwrap_or_default(),
236        top_p: request_context.and_then(|ctx| ctx.top_p),
237        truncation: request_context.and_then(|ctx| ctx.truncation.clone()),
238        user: request_context.and_then(|ctx| ctx.user.clone()),
239        metadata: request_context
240            .and_then(|ctx| ctx.metadata.clone())
241            .unwrap_or_default(),
242        service_tier: None,
243        top_logprobs: None,
244        usage,
245    })
246}
247
248/// Extract text content from a ChatMessage.
249fn extract_content(content: &Content) -> Option<String> {
250    let text = match content {
251        Content::String(s) => {
252            if s.is_empty() {
253                return None;
254            }
255            s.clone()
256        }
257        Content::Array(arr) => {
258            let text: String = arr
259                .iter()
260                .filter_map(|b| b.text.clone())
261                .collect::<Vec<_>>()
262                .join("");
263            if text.is_empty() {
264                return None;
265            }
266            text
267        }
268    };
269    Some(text)
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use crate::types::chat_api::{
276        ChatChoice, ChatMessage, ChatMessageAnnotation, CompletionTokensDetails, Content, MessageRole,
277        PromptTokensDetails,
278    };
279    use crate::types::response_api::{InputItemOrString, ResponseRequest, Tool, ToolChoice, ToolType};
280    use std::collections::HashMap;
281
282    #[test]
283    fn test_basic_response_conversion() {
284        let chat_resp = ChatResponse {
285            id: "chat_123".to_string(),
286            object_name: "chat.completion".to_string(),
287            created: 1234567890,
288            model: "gpt-4o".to_string(),
289            choices: vec![ChatChoice {
290                index: 0,
291                message: ChatMessage {
292                    role: MessageRole::Assistant,
293                    content: Content::String("Hello, how can I help you?".to_string()),
294                    name: None,
295                    annotations: None,
296                    tool_calls: None,
297                    tool_call_id: None,
298                    function_call: None,
299                    refusal: None,
300                },
301                finish_reason: Some("stop".to_string()),
302            }],
303            usage: Some(crate::types::chat_api::ChatUsage {
304                prompt_tokens: Some(10),
305                completion_tokens: Some(20),
306                total_tokens: Some(30),
307                prompt_tokens_details: None,
308                completion_tokens_details: None,
309            }),
310            service_tier: None,
311            system_fingerprint: None,
312        };
313
314        let response = chat_to_response(chat_resp).unwrap();
315
316        assert_eq!(response.status, "completed");
317        assert!(!response.output.is_empty());
318
319        let msg_output = response.output.first().unwrap();
320        assert_eq!(msg_output.item_type, OutputItemType::Message);
321
322        let text = msg_output.content.as_ref().and_then(|c| c.first());
323        match text {
324            Some(ResponseContentPart::OutputText { text, .. }) => {
325                assert_eq!(text, "Hello, how can I help you?");
326            }
327            _ => panic!("Expected output text"),
328        }
329
330        assert!(response.usage.is_some());
331        let usage = response.usage.unwrap();
332        assert_eq!(usage.input_tokens, Some(10));
333        assert_eq!(usage.output_tokens, Some(20));
334    }
335
336    #[test]
337    fn test_annotation_and_usage_details_mapping() {
338        let chat_resp = ChatResponse {
339            id: "chat_anno".to_string(),
340            object_name: "chat.completion".to_string(),
341            created: 1234567890,
342            model: "gpt-4o".to_string(),
343            choices: vec![ChatChoice {
344                index: 0,
345                message: ChatMessage {
346                    role: MessageRole::Assistant,
347                    content: Content::String("参考来源".to_string()),
348                    name: None,
349                    annotations: Some(vec![ChatMessageAnnotation::UrlCitation {
350                        start_index: 0,
351                        end_index: 4,
352                        url: "https://example.com".to_string(),
353                        title: "Example".to_string(),
354                    }]),
355                    tool_calls: None,
356                    tool_call_id: None,
357                    function_call: None,
358                    refusal: None,
359                },
360                finish_reason: Some("stop".to_string()),
361            }],
362            usage: Some(crate::types::chat_api::ChatUsage {
363                prompt_tokens: Some(10),
364                completion_tokens: Some(20),
365                total_tokens: Some(30),
366                prompt_tokens_details: Some(PromptTokensDetails {
367                    cached_tokens: Some(3),
368                }),
369                completion_tokens_details: Some(CompletionTokensDetails {
370                    reasoning_tokens: Some(7),
371                }),
372            }),
373            service_tier: None,
374            system_fingerprint: None,
375        };
376
377        let response = chat_to_response(chat_resp).unwrap();
378        let content = response.output[0].content.as_ref().unwrap();
379        match &content[0] {
380            ResponseContentPart::OutputText { annotations, .. } => {
381                assert!(!annotations.is_empty());
382            }
383            _ => panic!("expected output text"),
384        }
385        let usage = response.usage.unwrap();
386        assert_eq!(
387            usage.input_tokens_details.unwrap().cached_tokens,
388            3
389        );
390        assert_eq!(
391            usage.output_tokens_details.unwrap().reasoning_tokens,
392            7
393        );
394    }
395
396    #[test]
397    fn test_tool_call_conversion() {
398        let chat_resp = ChatResponse {
399            id: "chat_123".to_string(),
400            object_name: "chat.completion".to_string(),
401            created: 1234567890,
402            model: "gpt-4o".to_string(),
403            choices: vec![ChatChoice {
404                index: 0,
405                message: ChatMessage {
406                    role: MessageRole::Assistant,
407                    content: Content::String(String::new()),
408                    name: None,
409                    annotations: None,
410                    tool_calls: Some(vec![crate::types::chat_api::ToolCall {
411                        id: "call_abc".to_string(),
412                        tool_type: "function".to_string(),
413                        function: crate::types::chat_api::FunctionCall {
414                            name: "get_weather".to_string(),
415                            arguments: r#"{"city":"Beijing"}"#.to_string(),
416                        },
417                    }]),
418                    tool_call_id: None,
419                    function_call: None,
420                    refusal: None,
421                },
422                finish_reason: Some("tool_calls".to_string()),
423            }],
424            usage: None,
425            service_tier: None,
426            system_fingerprint: None,
427        };
428
429        let response = chat_to_response(chat_resp).unwrap();
430
431        // Should have function call output
432        let func_output = response
433            .output
434            .iter()
435            .find(|o| o.item_type == OutputItemType::FunctionCall);
436        assert!(func_output.is_some());
437
438        let func = func_output.unwrap();
439        assert_eq!(func.name.as_deref(), Some("get_weather"));
440        assert_eq!(func.arguments.as_deref(), Some(r#"{"city":"Beijing"}"#));
441    }
442
443    #[test]
444    fn test_builtin_tool_call_roundtrip_type_mapping() {
445        let chat_resp = ChatResponse {
446            id: "chat_123".to_string(),
447            object_name: "chat.completion".to_string(),
448            created: 1234567890,
449            model: "gpt-4o".to_string(),
450            choices: vec![ChatChoice {
451                index: 0,
452                message: ChatMessage {
453                    role: MessageRole::Assistant,
454                    content: Content::String(String::new()),
455                    name: None,
456                    annotations: None,
457                    tool_calls: Some(vec![crate::types::chat_api::ToolCall {
458                        id: "call_web".to_string(),
459                        tool_type: "function".to_string(),
460                        function: crate::types::chat_api::FunctionCall {
461                            name: "web_search_preview".to_string(),
462                            arguments: r#"{"query":"news"}"#.to_string(),
463                        },
464                    }]),
465                    tool_call_id: None,
466                    function_call: None,
467                    refusal: None,
468                },
469                finish_reason: Some("tool_calls".to_string()),
470            }],
471            usage: None,
472            service_tier: None,
473            system_fingerprint: None,
474        };
475
476        let req = ResponseRequest {
477            model: "gpt-4o".to_string(),
478            input: InputItemOrString::String("hi".to_string()),
479            instructions: None,
480            tools: vec![Tool {
481                tool_type: ToolType::WebSearchPreview,
482                name: Some("web_search_preview".to_string()),
483                description: None,
484                parameters: None,
485                strict: None,
486                extra: HashMap::new(),
487            }],
488            tool_choice: ToolChoice::Auto,
489            stream: false,
490            temperature: None,
491            max_output_tokens: None,
492            max_tokens: None,
493            top_p: None,
494            user: None,
495            reasoning: None,
496            text: None,
497            truncation: None,
498            store: None,
499            metadata: None,
500            previous_response_id: None,
501            parallel_tool_calls: None,
502            background: None,
503        };
504        let ctx = crate::convert::ResponseRequestContext::from(&req);
505        let response = chat_to_response_with_context(chat_resp, Some(&ctx)).unwrap();
506
507        let web = response
508            .output
509            .iter()
510            .find(|o| o.item_type == OutputItemType::WebSearchCall)
511            .expect("should map to web_search_call");
512        assert_eq!(web.call_id.as_deref(), Some("call_web"));
513    }
514
515    #[test]
516    fn test_parse_thought_tags() {
517        // No thought tags - should return original content
518        let (content, reasoning) = parse_thought_tags("Hello world");
519        assert_eq!(content, "Hello world");
520        assert!(reasoning.is_none());
521
522        // Single thought tag
523        let (content, reasoning) = parse_thought_tags("<thought>I should search</thought>Hello world");
524        assert_eq!(content, "Hello world");
525        assert_eq!(reasoning, Some("I should search".to_string()));
526
527        // Multiple thought tags - reasoning parts are joined with newlines
528        let (content, reasoning) = parse_thought_tags(
529            "<thought>Step 1: analyze</thought>Result1<thought>Step 2: conclude</thought>Final answer"
530        );
531        assert_eq!(content, "Result1Final answer");
532        assert_eq!(reasoning, Some("Step 1: analyze\n\nStep 2: conclude".to_string()));
533
534        // Unclosed thought tag
535        let (content, reasoning) = parse_thought_tags("<thought>unclosed Hello");
536        assert_eq!(content, "<thought>unclosed Hello");
537        assert!(reasoning.is_none());
538
539        // Content before and after thought
540        let (content, reasoning) = parse_thought_tags("Hello<thought>reasoning</thought>World");
541        assert_eq!(content, "HelloWorld");
542        assert_eq!(reasoning, Some("reasoning".to_string()));
543    }
544
545    #[test]
546    fn test_parse_think_tags() {
547        // MiniMax uses <think> tags instead of <thought>
548        let (content, reasoning) = parse_thought_tags("<think>\n分析当前目录\n</think>\n\n让我看看项目");
549        assert_eq!(content, "让我看看项目");
550        assert_eq!(reasoning, Some("\n分析当前目录\n".to_string()));
551
552        // Multiple think tags
553        let (content, reasoning) = parse_thought_tags(
554            "<think>Step 1</think>Result1<think>Step 2</think>Final"
555        );
556        assert_eq!(content, "Result1Final");
557        assert_eq!(reasoning, Some("Step 1\n\nStep 2".to_string()));
558
559        // Mixed tags (shouldn't happen but test robustness)
560        let (content, reasoning) = parse_thought_tags("<thought>A</thought>B<think>C</think>D");
561        assert_eq!(content, "BD");
562        assert_eq!(reasoning, Some("A\n\nC".to_string()));
563
564        // Empty think tag
565        let (content, reasoning) = parse_thought_tags("<think>Hello");
566        assert_eq!(content, "<think>Hello");
567        assert!(reasoning.is_none());
568    }
569
570    #[test]
571    fn test_finish_reason_length_maps_incomplete() {
572        let chat_resp = ChatResponse {
573            id: "chat_len".to_string(),
574            object_name: "chat.completion".to_string(),
575            created: 1234567890,
576            model: "gpt-4o".to_string(),
577            choices: vec![ChatChoice {
578                index: 0,
579                message: ChatMessage {
580                    role: MessageRole::Assistant,
581                    content: Content::String("partial".to_string()),
582                    name: None,
583                    annotations: None,
584                    tool_calls: None,
585                    tool_call_id: None,
586                    function_call: None,
587                    refusal: None,
588                },
589                finish_reason: Some("length".to_string()),
590            }],
591            usage: None,
592            service_tier: None,
593            system_fingerprint: None,
594        };
595        let response = chat_to_response(chat_resp).unwrap();
596        assert_eq!(response.status, "incomplete");
597        assert_eq!(
598            response
599                .incomplete_details
600                .as_ref()
601                .and_then(|v| v.get("reason"))
602                .and_then(|v| v.as_str()),
603            Some("max_output_tokens")
604        );
605    }
606
607    #[test]
608    fn test_legacy_function_call_is_converted() {
609        let chat_resp = ChatResponse {
610            id: "chat_fc".to_string(),
611            object_name: "chat.completion".to_string(),
612            created: 1234567890,
613            model: "gpt-4o".to_string(),
614            choices: vec![ChatChoice {
615                index: 0,
616                message: ChatMessage {
617                    role: MessageRole::Assistant,
618                    content: Content::String(String::new()),
619                    name: None,
620                    annotations: None,
621                    tool_calls: None,
622                    tool_call_id: None,
623                    function_call: Some(crate::types::chat_api::FunctionCall {
624                        name: "get_weather".to_string(),
625                        arguments: r#"{"city":"Beijing"}"#.to_string(),
626                    }),
627                    refusal: None,
628                },
629                finish_reason: Some("function_call".to_string()),
630            }],
631            usage: None,
632            service_tier: None,
633            system_fingerprint: None,
634        };
635        let response = chat_to_response(chat_resp).unwrap();
636        assert!(response
637            .output
638            .iter()
639            .any(|item| item.item_type == OutputItemType::FunctionCall));
640    }
641
642    #[test]
643    fn test_refusal_maps_to_message_refusal_content() {
644        let chat_resp = ChatResponse {
645            id: "chat_refuse".to_string(),
646            object_name: "chat.completion".to_string(),
647            created: 1234567890,
648            model: "gpt-4o".to_string(),
649            choices: vec![ChatChoice {
650                index: 0,
651                message: ChatMessage {
652                    role: MessageRole::Assistant,
653                    content: Content::String(String::new()),
654                    name: None,
655                    annotations: None,
656                    tool_calls: None,
657                    tool_call_id: None,
658                    function_call: None,
659                    refusal: Some("I cannot help with that.".to_string()),
660                },
661                finish_reason: Some("stop".to_string()),
662            }],
663            usage: None,
664            service_tier: None,
665            system_fingerprint: None,
666        };
667        let response = chat_to_response(chat_resp).unwrap();
668        let refusal_msg = response
669            .output
670            .iter()
671            .find(|item| {
672                item.content
673                    .as_ref()
674                    .is_some_and(|parts| parts.iter().any(|p| matches!(p, ResponseContentPart::Refusal { .. })))
675            })
676            .expect("refusal content should exist");
677        assert_eq!(refusal_msg.item_type, OutputItemType::Message);
678        let message_count = response
679            .output
680            .iter()
681            .filter(|item| item.item_type == OutputItemType::Message)
682            .count();
683        assert_eq!(message_count, 1, "refusal must be in same message item");
684        let parts = refusal_msg.content.as_ref().expect("message content should exist");
685        assert!(parts.iter().any(|p| matches!(
686            p,
687            ResponseContentPart::Refusal { refusal } if refusal == "I cannot help with that."
688        )));
689    }
690}