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::streaming::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.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
239fn 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 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 let (content, reasoning) = parse_thought_tags("Hello world");
510 assert_eq!(content, "Hello world");
511 assert!(reasoning.is_none());
512
513 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 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 let (content, reasoning) = parse_thought_tags("<thought>unclosed Hello");
527 assert_eq!(content, "<thought>unclosed Hello");
528 assert!(reasoning.is_none());
529
530 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 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 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 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 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}