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