Skip to main content

vtcode_core/llm/providers/openai/
responses_api.rs

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
486/// Build a standard (non-Codex) Responses API payload.
487pub 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                // Inject any persisted reasoning items from previous turns
526                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    // Responses API requires every tool call item to have a paired output item.
639    // Synthesize any missing outputs so replay cannot
640    // fail on partially paired history.
641    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}