1use crate::llm::error_display;
2use crate::llm::provider::{
3 AssistantPhase, ContentPart, FinishReason, LLMError, LLMRequest, LLMResponse, MessageContent,
4 MessageRole, ToolCall, Usage,
5};
6use crate::llm::providers::common::append_normalized_reasoning_detail_items;
7use crate::llm::providers::openai::types::OpenAIResponsesPayload;
8use crate::llm::providers::shared::{
9 collect_tool_references_from_tool_search_output, function_output_value_from_message_content,
10 parse_cached_prompt_tokens_from_usage, tool_result_content_from_message_content,
11};
12use hashbrown::HashMap;
13use serde_json::{Value, json};
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16enum ResponsesToolCallKind {
17 Function,
18 Custom,
19}
20
21fn responses_tool_call_kind(call: &ToolCall) -> ResponsesToolCallKind {
22 if call.is_custom() {
23 ResponsesToolCallKind::Custom
24 } else {
25 ResponsesToolCallKind::Function
26 }
27}
28
29fn responses_tool_call_output_type(kind: ResponsesToolCallKind) -> &'static str {
30 match kind {
31 ResponsesToolCallKind::Function => "function_call_output",
32 ResponsesToolCallKind::Custom => "custom_tool_call_output",
33 }
34}
35
36fn parse_responses_tool_call(item: &Value) -> Option<ToolCall> {
37 let item_type = item
38 .get("type")
39 .and_then(|value| value.as_str())
40 .unwrap_or("");
41 if item_type == "custom_tool_call" {
42 let call_id = item
43 .get("call_id")
44 .and_then(|v| v.as_str())
45 .or_else(|| item.get("id").and_then(|v| v.as_str()))
46 .unwrap_or("");
47 let name = item.get("name").and_then(|value| value.as_str())?;
48 let input = item
49 .get("input")
50 .and_then(|value| value.as_str())
51 .unwrap_or_default();
52 return Some(ToolCall::custom(
53 call_id.to_string(),
54 name.to_string(),
55 input.to_string(),
56 ));
57 }
58
59 parse_responses_function_tool_call(item)
60}
61
62fn parse_responses_function_tool_call(item: &Value) -> Option<ToolCall> {
63 let call_id = item
64 .get("call_id")
65 .and_then(|v| v.as_str())
66 .or_else(|| item.get("id").and_then(|v| v.as_str()))
67 .unwrap_or("");
68 let function_obj = item.get("function").and_then(|v| v.as_object());
69 let namespace = item
70 .get("namespace")
71 .and_then(|v| v.as_str())
72 .or_else(|| function_obj.and_then(|f| f.get("namespace").and_then(|n| n.as_str())))
73 .map(ToOwned::to_owned);
74 let name = function_obj
75 .and_then(|f| f.get("name").and_then(|n| n.as_str()))
76 .or_else(|| item.get("name").and_then(|n| n.as_str()))?;
77 let arguments = function_obj
78 .and_then(|f| f.get("arguments"))
79 .or_else(|| item.get("arguments"));
80
81 let serialized = arguments.map_or("{}".to_owned(), |args| {
82 if args.is_string() {
83 args.as_str().unwrap_or("{}").to_string()
84 } else {
85 args.to_string()
86 }
87 });
88
89 Some(ToolCall::function_with_namespace(
90 call_id.to_string(),
91 namespace,
92 name.to_string(),
93 serialized,
94 ))
95}
96
97fn append_user_content_parts(content_parts: &mut Vec<Value>, message_content: &MessageContent) {
98 match message_content {
99 MessageContent::Text(text) => {
100 if !text.trim().is_empty() {
101 content_parts.push(json!({
102 "type": "input_text",
103 "text": text
104 }));
105 }
106 }
107 MessageContent::Parts(parts) => {
108 for part in parts {
109 match part {
110 ContentPart::Text { text } => {
111 if !text.trim().is_empty() {
112 content_parts.push(json!({
113 "type": "input_text",
114 "text": text
115 }));
116 }
117 }
118 ContentPart::Image {
119 data, mime_type, ..
120 } => {
121 let image_url = {
122 let mut s = String::with_capacity(13 + mime_type.len() + data.len());
123 s.push_str("data:");
124 s.push_str(mime_type);
125 s.push_str(";base64,");
126 s.push_str(data);
127 s
128 };
129 content_parts.push(json!({
130 "type": "input_image",
131 "image_url": image_url
132 }));
133 }
134 ContentPart::File {
135 filename,
136 file_id,
137 file_data,
138 file_url,
139 ..
140 } => {
141 if file_id.is_none() && file_data.is_none() && file_url.is_none() {
142 continue;
143 }
144
145 let mut file_part = json!({
146 "type": "input_file"
147 });
148 if let Value::Object(ref mut map) = file_part {
149 if let Some(name) = filename {
150 map.insert("filename".to_owned(), json!(name));
151 }
152 if let Some(id) = file_id {
153 map.insert("file_id".to_owned(), json!(id));
154 }
155 if let Some(data) = file_data {
156 map.insert("file_data".to_owned(), json!(data));
157 }
158 if let Some(url) = file_url {
159 map.insert("file_url".to_owned(), json!(url));
160 }
161 }
162 content_parts.push(file_part);
163 }
164 }
165 }
166 }
167 }
168}
169
170fn assistant_input_item(content_parts: Vec<Value>, phase: Option<AssistantPhase>) -> Value {
171 let mut item = json!({
172 "role": "assistant",
173 "content": content_parts
174 });
175
176 if let Some(phase) = phase
177 && let Value::Object(ref mut map) = item
178 {
179 map.insert("phase".to_string(), json!(phase.as_str()));
180 }
181
182 item
183}
184
185fn append_assistant_text_to_instructions(instructions_segments: &mut Vec<String>, text: &str) {
186 let trimmed = text.trim();
187 if trimmed.is_empty() {
188 return;
189 }
190
191 let mut s = String::with_capacity(30 + trimmed.len());
192 s.push_str("Previous assistant response:\n");
193 s.push_str(trimmed);
194 instructions_segments.push(s);
195}
196
197fn append_output_item_text(value: &Value, text: &mut String) {
198 if let Some(part_text) = value.get("text").and_then(|value| value.as_str()) {
199 text.push_str(part_text);
200 }
201 if let Some(part_output) = value.get("output").and_then(|value| value.as_str()) {
202 text.push_str(part_output);
203 }
204 if let Some(refusal) = value.get("refusal").and_then(|value| value.as_str()) {
205 text.push_str(refusal);
206 }
207
208 match value {
209 Value::String(value) => text.push_str(value),
210 Value::Array(parts) => {
211 for part in parts {
212 append_output_item_text(part, text);
213 }
214 }
215 Value::Object(_) => {
216 if let Some(content) = value.get("content") {
217 append_output_item_text(content, text);
218 }
219 }
220 _ => {}
221 }
222}
223
224fn tool_result_history_text(message_content: &MessageContent) -> String {
225 let tool_content = tool_result_content_from_message_content(message_content);
226 if tool_content.is_empty() {
227 return String::new();
228 }
229
230 let mut text = String::new();
231 for item in &tool_content {
232 append_output_item_text(item, &mut text);
233 }
234
235 let trimmed = text.trim();
236 if !trimmed.is_empty() {
237 return trimmed.to_string();
238 }
239
240 Value::Array(tool_content).to_string()
241}
242
243fn append_tool_result_to_instructions(
244 instructions_segments: &mut Vec<String>,
245 tool_call_id: Option<&str>,
246 message_content: &MessageContent,
247) {
248 let text = tool_result_history_text(message_content);
249 if text.is_empty() {
250 return;
251 }
252
253 let (heading_str, heading_cap) = match tool_call_id {
254 Some(id) if !id.is_empty() => (None, 26 + id.len()),
255 _ => (Some("Previous tool result:"), 0),
256 };
257 let mut s =
258 String::with_capacity(heading_str.map_or(heading_cap, |h| h.len()) + 1 + text.len());
259 match heading_str {
260 Some(h) => s.push_str(h),
261 None => {
262 s.push_str("Previous tool result (");
263 s.push_str(tool_call_id.unwrap());
264 s.push_str("):");
265 }
266 }
267 s.push('\n');
268 s.push_str(&text);
269 instructions_segments.push(s);
270}
271
272pub fn parse_responses_payload(
273 response_json: Value,
274 model: String,
275 include_cached_prompt_metrics: bool,
276) -> Result<LLMResponse, LLMError> {
277 let output = response_json
278 .get("output")
279 .and_then(|value| value.as_array())
280 .ok_or_else(|| {
281 let formatted_error = error_display::format_llm_error(
282 "OpenAI",
283 "Invalid Responses API format: missing output array",
284 );
285 LLMError::Provider {
286 message: formatted_error,
287 metadata: None,
288 }
289 })?;
290
291 if output.is_empty() {
292 let formatted_error = error_display::format_llm_error("OpenAI", "No output in response");
293 return Err(LLMError::Provider {
294 message: formatted_error,
295 metadata: None,
296 });
297 }
298
299 let mut content_fragments: Vec<String> = Vec::new();
300 let mut reasoning_text_fragments: Vec<String> = Vec::new();
301 let mut reasoning_items: Vec<Value> = Vec::new();
302 let mut tool_calls_vec: Vec<ToolCall> = Vec::new();
303 let mut tool_references: Vec<String> = Vec::new();
304
305 for item in output {
306 let item_type = item
307 .get("type")
308 .and_then(|value| value.as_str())
309 .unwrap_or("");
310
311 match item_type {
312 "message" => {
313 if let Some(content_array) = item.get("content").and_then(|value| value.as_array())
314 {
315 for entry in content_array {
316 let entry_type = entry
317 .get("type")
318 .and_then(|value| value.as_str())
319 .unwrap_or("");
320
321 match entry_type {
322 "text" | "output_text" => {
323 if let Some(text) =
324 entry.get("text").and_then(|value| value.as_str())
325 && !text.is_empty()
326 {
327 content_fragments.push(text.to_string());
328 }
329 }
330 "reasoning" => {
331 if let Some(text) =
332 entry.get("text").and_then(|value| value.as_str())
333 && !text.is_empty()
334 {
335 reasoning_text_fragments.push(text.to_string());
336 }
337 }
338 "function_call" | "tool_call" | "custom_tool_call" => {
339 if let Some(call) = parse_responses_tool_call(entry) {
340 tool_calls_vec.push(call);
341 }
342 }
343 "refusal" => {
344 if let Some(refusal_text) =
345 entry.get("refusal").and_then(|value| value.as_str())
346 && !refusal_text.is_empty()
347 {
348 content_fragments.push(format!("[Refusal: {}]", refusal_text));
349 }
350 }
351 _ => {}
352 }
353 }
354 }
355 }
356 "function_call" | "tool_call" | "custom_tool_call" => {
357 if let Some(call) = parse_responses_tool_call(item) {
358 tool_calls_vec.push(call);
359 }
360 }
361 "tool_search_output" => {
362 collect_tool_references_from_tool_search_output(item, &mut tool_references);
363 }
364 "web_search" | "file_search" => {
365 if let Some(results) = item.get("results").and_then(|r| r.as_array()) {
366 let citations: Vec<String> = results
367 .iter()
368 .filter_map(|r| {
369 let title = r
370 .get("title")
371 .and_then(|v| v.as_str())
372 .unwrap_or("Untitled");
373 let url = r.get("url").and_then(|v| v.as_str()).unwrap_or("");
374 if !url.is_empty() {
375 Some(format!("[{}]({})", title, url))
376 } else {
377 None
378 }
379 })
380 .collect();
381 if !citations.is_empty() {
382 content_fragments.push(format!("\n\nSources:\n{}", citations.join("\n")));
383 }
384 }
385 }
386 "reasoning" => {
387 reasoning_items.push(item.clone());
388
389 if let Some(summary_array) = item.get("summary").and_then(|v| v.as_array()) {
390 for summary_part in summary_array {
391 if let Some(text) = summary_part.get("text").and_then(|v| v.as_str())
392 && !text.is_empty()
393 {
394 reasoning_text_fragments.push(text.to_string());
395 }
396 }
397 }
398 }
399 _ => {}
400 }
401 }
402
403 let content = if content_fragments.is_empty() {
404 None
405 } else {
406 Some(content_fragments.join(""))
407 };
408
409 let reasoning = if reasoning_text_fragments.is_empty() {
410 None
411 } else {
412 Some(reasoning_text_fragments.join("\n\n"))
413 };
414
415 let reasoning_details = if reasoning_items.is_empty() {
416 None
417 } else {
418 Some(reasoning_items.into_iter().map(|v| v.to_string()).collect())
419 };
420
421 let finish_reason = if !tool_calls_vec.is_empty() {
422 FinishReason::ToolCalls
423 } else {
424 FinishReason::Stop
425 };
426
427 let tool_calls = if tool_calls_vec.is_empty() {
428 None
429 } else {
430 Some(tool_calls_vec)
431 };
432
433 let usage = response_json.get("usage").map(|usage_value| {
434 let cached_prompt_tokens =
435 parse_cached_prompt_tokens_from_usage(usage_value, include_cached_prompt_metrics);
436
437 Usage {
438 prompt_tokens: usage_value
439 .get("input_tokens")
440 .or_else(|| usage_value.get("prompt_tokens"))
441 .and_then(|pt| pt.as_u64())
442 .and_then(|v| u32::try_from(v).ok())
443 .unwrap_or(0),
444 completion_tokens: usage_value
445 .get("output_tokens")
446 .or_else(|| usage_value.get("completion_tokens"))
447 .and_then(|ct| ct.as_u64())
448 .and_then(|v| u32::try_from(v).ok())
449 .unwrap_or(0),
450 total_tokens: usage_value
451 .get("total_tokens")
452 .and_then(|tt| tt.as_u64())
453 .and_then(|v| u32::try_from(v).ok())
454 .unwrap_or(0),
455 cached_prompt_tokens,
456 cache_creation_tokens: None,
457 cache_read_tokens: None,
458 iterations: None,
459 }
460 });
461
462 Ok(LLMResponse {
463 content,
464 tool_calls,
465 model,
466 usage,
467 finish_reason,
468 reasoning,
469 reasoning_details,
470 tool_references,
471 request_id: response_json
472 .get("id")
473 .and_then(|value| value.as_str())
474 .map(ToOwned::to_owned)
475 .or_else(|| {
476 response_json
477 .get("request_id")
478 .and_then(|value| value.as_str())
479 .map(ToOwned::to_owned)
480 }),
481 organization_id: None,
482 compaction: None,
483 })
484}
485
486pub fn build_standard_responses_payload(
488 request: &LLMRequest,
489 include_structured_history_in_input: bool,
490) -> Result<OpenAIResponsesPayload, LLMError> {
491 let mut input = Vec::new();
492 let mut active_tool_calls: HashMap<String, ResponsesToolCallKind> = HashMap::new();
493 let mut pending_tool_call_order: Vec<String> = Vec::new();
494 let mut deferred_tool_outputs: HashMap<String, Value> = HashMap::new();
495 let mut instructions_segments = Vec::new();
496
497 if let Some(system_prompt) = &request.system_prompt {
498 let trimmed = system_prompt.trim();
499 if !trimmed.is_empty() {
500 instructions_segments.push(trimmed.to_string());
501 }
502 }
503
504 for msg in &request.messages {
505 match msg.role {
506 MessageRole::System => {
507 let content_text = msg.content.as_text();
508 let trimmed = content_text.trim();
509 if !trimmed.is_empty() {
510 instructions_segments.push(trimmed.to_string());
511 }
512 }
513 MessageRole::User => {
514 let mut content_parts: Vec<Value> = Vec::new();
515 append_user_content_parts(&mut content_parts, &msg.content);
516
517 if !content_parts.is_empty() {
518 input.push(json!({
519 "role": "user",
520 "content": content_parts
521 }));
522 }
523 }
524 MessageRole::Assistant => {
525 if include_structured_history_in_input
527 && let Some(reasoning_details) = &msg.reasoning_details
528 {
529 append_normalized_reasoning_detail_items(&mut input, reasoning_details);
530 }
531
532 let mut content_parts = Vec::new();
533 let mut tool_call_items = Vec::new();
534 if !msg.content.is_empty() {
535 if include_structured_history_in_input {
536 content_parts.push(json!({
537 "type": "output_text",
538 "text": msg.content.as_text()
539 }));
540 } else {
541 append_assistant_text_to_instructions(
542 &mut instructions_segments,
543 &msg.content.as_text(),
544 );
545 }
546 }
547
548 if let Some(tool_calls) = &msg.tool_calls {
549 for call in tool_calls {
550 if let Some(ref func) = call.function {
551 let call_kind = responses_tool_call_kind(call);
552 if active_tool_calls
553 .insert(call.id.clone(), call_kind)
554 .is_none()
555 {
556 pending_tool_call_order.push(call.id.clone());
557 }
558 if include_structured_history_in_input {
559 let replay_item = match call_kind {
560 ResponsesToolCallKind::Function => json!({
561 "type": "function_call",
562 "call_id": &call.id,
563 "name": &func.name,
564 "arguments": &func.arguments
565 }),
566 ResponsesToolCallKind::Custom => json!({
567 "type": "custom_tool_call",
568 "call_id": &call.id,
569 "name": &func.name,
570 "input": call.text.as_deref().unwrap_or(&func.arguments)
571 }),
572 };
573 tool_call_items.push(replay_item);
574 if let Some(deferred_output) =
575 deferred_tool_outputs.remove(&call.id)
576 {
577 active_tool_calls.remove(&call.id);
578 tool_call_items.push(json!({
579 "type": responses_tool_call_output_type(call_kind),
580 "call_id": &call.id,
581 "output": deferred_output,
582 }));
583 }
584 }
585 }
586 }
587 }
588
589 if !content_parts.is_empty() {
590 input.push(assistant_input_item(content_parts, msg.phase));
591 }
592 input.extend(tool_call_items);
593 }
594 MessageRole::Tool => {
595 let tool_call_id = msg.tool_call_id.as_ref().ok_or_else(|| {
596 let formatted_error = error_display::format_llm_error(
597 "OpenAI",
598 "Tool messages must include tool_call_id for Responses API",
599 );
600 LLMError::InvalidRequest {
601 message: formatted_error,
602 metadata: None,
603 }
604 })?;
605
606 if !active_tool_calls.contains_key(tool_call_id) {
607 if include_structured_history_in_input {
608 deferred_tool_outputs.insert(
609 tool_call_id.clone(),
610 function_output_value_from_message_content(&msg.content),
611 );
612 }
613 continue;
614 }
615
616 if !include_structured_history_in_input {
617 append_tool_result_to_instructions(
618 &mut instructions_segments,
619 Some(tool_call_id),
620 &msg.content,
621 );
622 active_tool_calls.remove(tool_call_id);
623 continue;
624 }
625
626 let call_kind = active_tool_calls
627 .remove(tool_call_id)
628 .unwrap_or(ResponsesToolCallKind::Function);
629 input.push(json!({
630 "type": responses_tool_call_output_type(call_kind),
631 "call_id": tool_call_id,
632 "output": function_output_value_from_message_content(&msg.content),
633 }));
634 }
635 }
636 }
637
638 if include_structured_history_in_input {
642 for call_id in pending_tool_call_order {
643 let Some(call_kind) = active_tool_calls.remove(&call_id) else {
644 continue;
645 };
646 input.push(json!({
647 "type": responses_tool_call_output_type(call_kind),
648 "call_id": call_id,
649 "output": "aborted",
650 }));
651 }
652 }
653
654 let instructions = if instructions_segments.is_empty() {
655 None
656 } else {
657 Some(instructions_segments.join("\n\n"))
658 };
659
660 Ok(OpenAIResponsesPayload {
661 input,
662 instructions,
663 })
664}
665
666#[cfg(test)]
667mod tests {
668 use super::{build_standard_responses_payload, parse_responses_payload};
669 use crate::llm::provider::{LLMRequest, Message, ToolCall};
670 use crate::llm::providers::shared::parse_cached_prompt_tokens_from_usage;
671 use serde_json::{Value, json};
672
673 fn assert_multimodal_tool_result(payload: super::OpenAIResponsesPayload) {
674 let tool_msg = payload
675 .input
676 .iter()
677 .find(|item| item.get("type").and_then(Value::as_str) == Some("function_call_output"))
678 .expect("function_call_output should exist");
679 let tool_result_content = tool_msg
680 .get("output")
681 .and_then(Value::as_array)
682 .expect("function_call_output output should be an array");
683
684 assert_eq!(tool_result_content.len(), 2);
685 assert_eq!(tool_result_content[0]["type"], "input_text");
686 assert_eq!(tool_result_content[0]["text"], "inline image note");
687 assert_eq!(tool_result_content[1]["type"], "input_image");
688 assert_eq!(
689 tool_result_content[1]["image_url"],
690 "data:image/png;base64,abc"
691 );
692 }
693
694 #[test]
695 fn standard_payload_normalizes_stringified_reasoning_details_items() {
696 let request = LLMRequest {
697 model: "gpt-5".to_string(),
698 messages: vec![
699 Message::assistant("answer".to_string()).with_reasoning_details(Some(vec![
700 json!(r#"{"type":"compaction","id":"cmp_1","encrypted_content":"opaque"}"#),
701 json!("plain-text"),
702 ])),
703 ],
704 ..Default::default()
705 };
706
707 let payload =
708 build_standard_responses_payload(&request, true).expect("payload should build");
709 assert_eq!(payload.input.len(), 2);
710 assert_eq!(payload.input[0]["type"], "compaction");
711 }
712
713 #[test]
714 fn standard_payload_preserves_multimodal_tool_result_content() {
715 let request = LLMRequest {
716 model: "gpt-5".to_string(),
717 messages: vec![
718 Message::assistant_with_tools(
719 String::new(),
720 vec![ToolCall::function(
721 "call_1".to_string(),
722 "view_image".to_string(),
723 "{\"path\":\"./img.png\"}".to_string(),
724 )],
725 ),
726 Message::tool_response(
727 "call_1".to_string(),
728 r#"[{"type":"input_text","text":"inline image note"},{"type":"input_image","image_url":"data:image/png;base64,abc"}]"#
729 .to_string(),
730 ),
731 ],
732 ..Default::default()
733 };
734
735 let payload =
736 build_standard_responses_payload(&request, true).expect("payload should build");
737 assert_multimodal_tool_result(payload);
738 }
739
740 #[test]
741 fn standard_payload_uses_responses_function_call_items_for_structured_tool_history() {
742 let request = LLMRequest {
743 model: "gpt-5.3-codex".to_string(),
744 messages: vec![
745 Message::user("run cargo fmt".to_string()),
746 Message::assistant_with_tools(
747 String::new(),
748 vec![ToolCall::function(
749 "direct_unified_exec_1".to_string(),
750 "unified_exec".to_string(),
751 "{\"command\":\"cargo fmt\"}".to_string(),
752 )],
753 ),
754 Message::tool_response(
755 "direct_unified_exec_1".to_string(),
756 "{\"output\":\"\",\"exit_code\":0,\"backend\":\"pipe\"}".to_string(),
757 ),
758 Message::assistant("cargo fmt completed successfully.".to_string()),
759 ],
760 ..Default::default()
761 };
762
763 let payload =
764 build_standard_responses_payload(&request, true).expect("payload should build");
765
766 assert_eq!(payload.input.len(), 4);
767 assert_eq!(payload.input[0]["role"], "user");
768 assert_eq!(payload.input[1]["type"], "function_call");
769 assert!(payload.input[1].get("id").is_none());
770 assert_eq!(payload.input[1]["call_id"], "direct_unified_exec_1");
771 assert_eq!(payload.input[2]["type"], "function_call_output");
772 assert_eq!(payload.input[2]["call_id"], "direct_unified_exec_1");
773 assert_eq!(
774 payload.input[2]["output"],
775 "{\"output\":\"\",\"exit_code\":0,\"backend\":\"pipe\"}"
776 );
777 assert_eq!(payload.input[3]["role"], "assistant");
778 }
779
780 #[test]
781 fn standard_payload_synthesizes_missing_function_call_output_for_orphan_call() {
782 let request = LLMRequest {
783 model: "gpt-5.3-codex".to_string(),
784 messages: vec![
785 Message::user("run cargo fmt".to_string()),
786 Message::assistant_with_tools(
787 String::new(),
788 vec![ToolCall::function(
789 "call_orphan".to_string(),
790 "unified_exec".to_string(),
791 "{\"command\":\"cargo fmt\"}".to_string(),
792 )],
793 ),
794 Message::user("continue".to_string()),
795 ],
796 ..Default::default()
797 };
798
799 let payload =
800 build_standard_responses_payload(&request, true).expect("payload should build");
801
802 assert!(payload.input.iter().any(|item| {
803 item.get("type").and_then(Value::as_str) == Some("function_call")
804 && item.get("call_id").and_then(Value::as_str) == Some("call_orphan")
805 }));
806 assert!(payload.input.iter().any(|item| {
807 item.get("type").and_then(Value::as_str) == Some("function_call_output")
808 && item.get("call_id").and_then(Value::as_str) == Some("call_orphan")
809 && item.get("output").and_then(Value::as_str) == Some("aborted")
810 }));
811 }
812
813 #[test]
814 fn standard_payload_pairs_deferred_tool_output_when_output_precedes_call() {
815 let request = LLMRequest {
816 model: "gpt-5.3-codex".to_string(),
817 messages: vec![
818 Message::user("continue".to_string()),
819 Message::tool_response("call_1".to_string(), "{\"output\":\"late\"}".to_string()),
820 Message::assistant_with_tools(
821 String::new(),
822 vec![ToolCall::function(
823 "call_1".to_string(),
824 "unified_exec".to_string(),
825 "{\"command\":\"echo late\"}".to_string(),
826 )],
827 ),
828 ],
829 ..Default::default()
830 };
831
832 let payload =
833 build_standard_responses_payload(&request, true).expect("payload should build");
834
835 let call_index = payload
836 .input
837 .iter()
838 .position(|item| {
839 item.get("type").and_then(Value::as_str) == Some("function_call")
840 && item.get("call_id").and_then(Value::as_str) == Some("call_1")
841 })
842 .expect("function_call should exist");
843 let output_index = payload
844 .input
845 .iter()
846 .position(|item| {
847 item.get("type").and_then(Value::as_str) == Some("function_call_output")
848 && item.get("call_id").and_then(Value::as_str) == Some("call_1")
849 })
850 .expect("function_call_output should exist");
851
852 assert!(output_index > call_index);
853 assert_eq!(
854 payload.input[output_index]["output"],
855 "{\"output\":\"late\"}"
856 );
857 assert_ne!(payload.input[output_index]["output"], "aborted");
858 }
859
860 #[test]
861 fn standard_payload_omits_function_call_id_for_codex_replay_shape() {
862 let request = LLMRequest {
863 model: "gpt-5.1-codex".to_string(),
864 messages: vec![
865 Message::user("run cargo fmt and report".to_string()),
866 Message::assistant_with_tools(
867 String::new(),
868 vec![ToolCall::function(
869 "call_T4IsdQtJifUHQUXutDlwoFLd".to_string(),
870 "unified_exec".to_string(),
871 r#"{"command":"cd /Users/vinhnguyenxuan/Developer/learn-by-doing/vtcode && cargo fmt","workdir":"/Users/vinhnguyenxuan/Developer/learn-by-doing/vtcode","sandbox_permissions":"use_default","additional_permissions":{"fs_read":[],"fs_write":[]}}"#.to_string(),
872 )],
873 ),
874 Message::tool_response(
875 "call_T4IsdQtJifUHQUXutDlwoFLd".to_string(),
876 r#"{"output":"","exit_code":0,"backend":"pipe"}"#.to_string(),
877 ),
878 Message::system(
879 "Previous turn already completed tool execution. Reuse the latest tool outputs in history instead of rerunning the same exploration.".to_string(),
880 ),
881 Message::user("ok".to_string()),
882 ],
883 ..Default::default()
884 };
885
886 let payload =
887 build_standard_responses_payload(&request, true).expect("payload should build");
888 let function_call = payload
889 .input
890 .iter()
891 .find(|item| item.get("type").and_then(Value::as_str) == Some("function_call"))
892 .expect("function_call item should exist");
893
894 assert_eq!(
895 function_call.get("call_id").and_then(Value::as_str),
896 Some("call_T4IsdQtJifUHQUXutDlwoFLd")
897 );
898 assert!(
899 function_call.get("id").is_none(),
900 "function_call replay items should omit id"
901 );
902 }
903
904 #[test]
905 fn parse_responses_payload_prefers_call_id_for_tool_correlation() {
906 let response = json!({
907 "output": [
908 {
909 "type": "function_call",
910 "id": "fc_123",
911 "call_id": "call_123",
912 "name": "unified_exec",
913 "arguments": "{\"command\":\"cargo fmt\"}"
914 }
915 ]
916 });
917
918 let parsed = parse_responses_payload(response, "gpt-5.3-codex".to_string(), false)
919 .expect("payload should parse");
920
921 let tool_calls = parsed.tool_calls.expect("tool calls should exist");
922 assert_eq!(tool_calls.len(), 1);
923 assert_eq!(tool_calls[0].id, "call_123");
924 assert_eq!(
925 tool_calls[0]
926 .function
927 .as_ref()
928 .map(|function| function.name.as_str()),
929 Some("unified_exec")
930 );
931 }
932
933 #[test]
934 fn parse_responses_payload_preserves_function_namespace() {
935 let response = json!({
936 "output": [
937 {
938 "type": "function_call",
939 "id": "fc_456",
940 "call_id": "call_456",
941 "namespace": "repo_browser",
942 "name": "list_files",
943 "arguments": "{\"path\":\".\"}"
944 }
945 ]
946 });
947
948 let parsed = parse_responses_payload(response, "gpt-5.3-codex".to_string(), false)
949 .expect("payload should parse");
950
951 let tool_calls = parsed.tool_calls.expect("tool calls should exist");
952 let namespace = tool_calls[0]
953 .function
954 .as_ref()
955 .and_then(|function| function.namespace.as_deref());
956
957 assert_eq!(namespace, Some("repo_browser"));
958 }
959
960 #[test]
961 fn parse_responses_payload_parses_custom_tool_calls() {
962 let response = json!({
963 "output": [
964 {
965 "type": "custom_tool_call",
966 "id": "ct_123",
967 "call_id": "call_patch_1",
968 "name": "apply_patch",
969 "input": "*** Begin Patch\n*** End Patch\n"
970 }
971 ]
972 });
973
974 let parsed = parse_responses_payload(response, "gpt-5.3-codex".to_string(), false)
975 .expect("payload should parse");
976
977 let tool_calls = parsed.tool_calls.expect("tool calls should exist");
978 assert_eq!(tool_calls.len(), 1);
979 assert!(tool_calls[0].is_custom());
980 assert_eq!(tool_calls[0].id, "call_patch_1");
981 assert_eq!(tool_calls[0].tool_name(), Some("apply_patch"));
982 assert_eq!(
983 tool_calls[0].raw_input(),
984 Some("*** Begin Patch\n*** End Patch\n")
985 );
986 }
987
988 #[test]
989 fn standard_payload_replays_custom_tool_turns_with_custom_items() {
990 let request = LLMRequest {
991 messages: vec![
992 Message::user("Apply patch".to_string()),
993 Message::assistant_with_tools(
994 String::new(),
995 vec![ToolCall::custom(
996 "call_patch_1".to_string(),
997 "apply_patch".to_string(),
998 "*** Begin Patch\n*** End Patch\n".to_string(),
999 )],
1000 ),
1001 Message::tool_response("call_patch_1".to_string(), "patched".to_string()),
1002 ],
1003 ..Default::default()
1004 };
1005
1006 let payload =
1007 build_standard_responses_payload(&request, true).expect("payload should build");
1008
1009 assert_eq!(payload.input.len(), 3);
1010 assert_eq!(payload.input[1]["type"], "custom_tool_call");
1011 assert_eq!(payload.input[1]["call_id"], "call_patch_1");
1012 assert_eq!(payload.input[1]["name"], "apply_patch");
1013 assert_eq!(
1014 payload.input[1]["input"],
1015 "*** Begin Patch\n*** End Patch\n"
1016 );
1017 assert_eq!(payload.input[2]["type"], "custom_tool_call_output");
1018 assert_eq!(payload.input[2]["call_id"], "call_patch_1");
1019 assert_eq!(payload.input[2]["output"], "patched");
1020 }
1021
1022 #[test]
1023 fn parse_responses_payload_extracts_tool_search_references() {
1024 let response = json!({
1025 "output": [
1026 {
1027 "type": "tool_search_output",
1028 "execution": "client",
1029 "status": "completed",
1030 "tools": [
1031 {
1032 "name": "read_file",
1033 "description": "Read a file"
1034 },
1035 {
1036 "name": "namespace_group",
1037 "tools": [
1038 {
1039 "name": "write_file",
1040 "description": "Write a file"
1041 }
1042 ]
1043 }
1044 ]
1045 }
1046 ]
1047 });
1048
1049 let parsed = parse_responses_payload(response, "gpt-5.3-codex".to_string(), false)
1050 .expect("payload should parse");
1051
1052 assert_eq!(
1053 parsed.tool_references,
1054 vec!["read_file".to_string(), "write_file".to_string()]
1055 );
1056 }
1057
1058 #[test]
1059 fn standard_payload_can_move_assistant_text_history_into_instructions() {
1060 let request = LLMRequest {
1061 model: "gpt-5.2-codex".to_string(),
1062 messages: vec![
1063 Message::user("What is this project?".to_string()),
1064 Message::assistant("VT Code is a Rust Cargo workspace.".to_string()),
1065 Message::user("Tell me more.".to_string()),
1066 ],
1067 ..Default::default()
1068 };
1069
1070 let payload =
1071 build_standard_responses_payload(&request, false).expect("payload should build");
1072
1073 assert_eq!(payload.input.len(), 2);
1074 assert_eq!(payload.input[0]["role"], "user");
1075 assert_eq!(payload.input[1]["role"], "user");
1076 assert_eq!(
1077 payload.instructions.as_deref(),
1078 Some("Previous assistant response:\nVT Code is a Rust Cargo workspace.")
1079 );
1080 }
1081
1082 #[test]
1083 fn standard_payload_can_omit_reasoning_details_from_input() {
1084 let request = LLMRequest {
1085 model: "gpt-5.2-codex".to_string(),
1086 messages: vec![
1087 Message::assistant("answer".to_string()).with_reasoning_details(Some(vec![
1088 json!({
1089 "type": "reasoning",
1090 "id": "rs_1",
1091 "summary": [{"type":"summary_text","text":"opaque"}]
1092 }),
1093 ])),
1094 Message::user("next".to_string()),
1095 ],
1096 ..Default::default()
1097 };
1098
1099 let payload =
1100 build_standard_responses_payload(&request, false).expect("payload should build");
1101
1102 assert_eq!(payload.input.len(), 1);
1103 assert_eq!(payload.input[0]["role"], "user");
1104 }
1105
1106 #[test]
1107 fn standard_payload_can_move_tool_turn_history_into_instructions() {
1108 let request = LLMRequest {
1109 model: "gpt-5.2-codex".to_string(),
1110 messages: vec![
1111 Message::user("run cargo check".to_string()),
1112 Message::assistant_with_tools(
1113 String::new(),
1114 vec![ToolCall::function(
1115 "call_1".to_string(),
1116 "unified_exec".to_string(),
1117 "{\"command\":\"cargo check\"}".to_string(),
1118 )],
1119 ),
1120 Message::tool_response(
1121 "call_1".to_string(),
1122 "{\"output\":\"Finished `dev` profile\",\"exit_code\":0}".to_string(),
1123 ),
1124 Message::assistant("cargo check completed successfully.".to_string()),
1125 Message::user("tell me more".to_string()),
1126 ],
1127 ..Default::default()
1128 };
1129
1130 let payload =
1131 build_standard_responses_payload(&request, false).expect("payload should build");
1132
1133 assert_eq!(payload.input.len(), 2);
1134 assert_eq!(payload.input[0]["role"], "user");
1135 assert_eq!(payload.input[1]["role"], "user");
1136 let instructions = payload.instructions.expect("instructions should exist");
1137 assert!(instructions.contains("Previous tool result (call_1):"));
1138 assert!(instructions.contains("Finished `dev` profile"));
1139 assert!(
1140 instructions
1141 .contains("Previous assistant response:\ncargo check completed successfully.")
1142 );
1143 }
1144
1145 #[test]
1146 fn parse_responses_payload_ignores_hosted_shell_trace_items() {
1147 let response = json!({
1148 "output": [
1149 {
1150 "type": "shell_call",
1151 "id": "sh_1",
1152 "status": "completed",
1153 "action": { "type": "command", "command": ["pwd"] }
1154 },
1155 {
1156 "type": "shell_call_output",
1157 "id": "sho_1",
1158 "call_id": "sh_1",
1159 "output": "workspace\n"
1160 },
1161 {
1162 "type": "message",
1163 "content": [
1164 { "type": "output_text", "text": "Done." }
1165 ]
1166 }
1167 ]
1168 });
1169
1170 let parsed =
1171 parse_responses_payload(response, "gpt-5".to_string(), false).expect("should parse");
1172
1173 assert_eq!(parsed.content.as_deref(), Some("Done."));
1174 assert!(parsed.tool_calls.unwrap_or_default().is_empty());
1175 }
1176
1177 #[test]
1178 fn parse_responses_payload_extracts_cached_prompt_tokens_from_input_details() {
1179 let response = json!({
1180 "output": [
1181 {
1182 "type": "message",
1183 "content": [
1184 {"type": "output_text", "text": "hello"}
1185 ]
1186 }
1187 ],
1188 "usage": {
1189 "input_tokens": 100,
1190 "output_tokens": 5,
1191 "total_tokens": 105,
1192 "input_tokens_details": {
1193 "cached_tokens": 42
1194 }
1195 }
1196 });
1197
1198 let parsed = parse_responses_payload(response, "gpt-5".to_string(), true)
1199 .expect("payload should parse");
1200
1201 assert_eq!(
1202 parsed.usage.and_then(|usage| usage.cached_prompt_tokens),
1203 Some(42)
1204 );
1205 }
1206
1207 #[test]
1208 fn parse_responses_payload_treats_missing_cached_prompt_tokens_as_normal() {
1209 let response = json!({
1210 "output": [
1211 {
1212 "type": "message",
1213 "content": [
1214 {"type": "output_text", "text": "hello"}
1215 ]
1216 }
1217 ],
1218 "usage": {
1219 "input_tokens": 100,
1220 "output_tokens": 5,
1221 "total_tokens": 105
1222 }
1223 });
1224
1225 let parsed = parse_responses_payload(response, "gpt-5".to_string(), true)
1226 .expect("payload should parse");
1227
1228 assert_eq!(
1229 parsed.usage.and_then(|usage| usage.cached_prompt_tokens),
1230 None
1231 );
1232 }
1233
1234 #[test]
1235 fn parse_cached_prompt_tokens_supports_responses_fallback_shapes() {
1236 assert_eq!(
1237 parse_cached_prompt_tokens_from_usage(
1238 &json!({"prompt_tokens_details": {"cached_tokens": 17}}),
1239 true
1240 ),
1241 Some(17)
1242 );
1243 assert_eq!(
1244 parse_cached_prompt_tokens_from_usage(&json!({"prompt_cache_hit_tokens": 23}), true),
1245 Some(23)
1246 );
1247 assert_eq!(
1248 parse_cached_prompt_tokens_from_usage(&json!({"cached_tokens": 29}), true),
1249 Some(29)
1250 );
1251 assert_eq!(
1252 parse_cached_prompt_tokens_from_usage(
1253 &json!({"input_tokens_details": {"cached_tokens": 31}}),
1254 false
1255 ),
1256 None
1257 );
1258 }
1259}