1use 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
12pub fn chat_to_response(chat_resp: ChatResponse) -> Result<ResponseObject, ConversionError> {
14 chat_to_response_with_context(chat_resp, None)
15}
16
17pub 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 if let Some(content) = extract_content(&choice.message.content) {
78 let (actual_content, reasoning) = parse_thought_tags(&content);
79
80 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 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 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 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, output: outputs,
221 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
248fn 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 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 let (content, reasoning) = parse_thought_tags("Hello world");
519 assert_eq!(content, "Hello world");
520 assert!(reasoning.is_none());
521
522 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 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 let (content, reasoning) = parse_thought_tags("<thought>unclosed Hello");
536 assert_eq!(content, "<thought>unclosed Hello");
537 assert!(reasoning.is_none());
538
539 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 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 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 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 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}