Skip to main content

switchyard_translation/codecs/responses/
buffered.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Buffered codec for OpenAI Responses request and response JSON.
5
6use std::collections::HashMap;
7
8use serde_json::{Map, Value, json};
9
10use crate::codecs::common::{
11    collect_responses_reasoning_text, encrypted_reasoning_data, encrypted_reasoning_item_id,
12    is_anthropic_request, is_known_role_name, provider_extensions, reasoning_text_from_blocks,
13    text_from_blocks,
14};
15use crate::codecs::openai_chat::{decode_file_source, decode_image_source};
16use crate::codecs::openai_media::{
17    ImagePayload, file_payload, file_source_text, image_payload, image_source_text,
18};
19use crate::codecs::structured_output::{decode_openai_schema_enforcement, encode_openai_format};
20use crate::codecs::{
21    DecodedRequest, DecodedResponse, EncodedRequest, EncodedResponse, FormatCodec,
22};
23use crate::diagnostic::TranslationDiagnostic;
24use crate::error::{Result, TranslationError};
25use crate::format::{FormatId, WireFormat};
26use crate::llm::{
27    AggLlmResponse, ContentBlock, FileSource, ImageSource, LlmRequest, MediaSource, Message,
28    OutputParams, ProviderExtensions, ReasoningParams, ResponseOutput, Role, SamplingParams,
29    StopReason, Tool, ToolCall, ToolChoice, ToolDefinition, ToolResult, Usage,
30};
31use crate::policy::{DeterministicIdPolicy, TranslationPolicy};
32use crate::util::{
33    capture_request_preservation, capture_response_preservation, embed_preservation,
34    exact_preserved_request, exact_preserved_response, is_responses_native_input_item, json_string,
35    push_lossy, stable_id, string_value, validate_request_capabilities,
36};
37
38/// Format codec for OpenAI Responses payloads.
39pub struct OpenAiResponsesCodec;
40
41impl FormatCodec for OpenAiResponsesCodec {
42    fn format(&self) -> FormatId {
43        WireFormat::OpenAiResponses.into()
44    }
45
46    fn decode_request(&self, body: &Value, policy: &TranslationPolicy) -> Result<DecodedRequest> {
47        let body = crate::util::object(body, "$")?;
48        let mut diagnostics = Vec::new();
49        let response_format = decode_responses_text_format(body.get("text"));
50        let mut request = LlmRequest {
51            model: body
52                .get("model")
53                .and_then(Value::as_str)
54                .filter(|model| !model.is_empty())
55                .map(ToOwned::to_owned),
56            output: OutputParams {
57                max_output_tokens: body.get("max_output_tokens").and_then(Value::as_u64),
58                is_schema_enforced: decode_openai_schema_enforcement(response_format.as_ref()),
59                response_format,
60            },
61            reasoning: ReasoningParams {
62                effort: body
63                    .get("reasoning")
64                    .and_then(Value::as_object)
65                    .and_then(|object| object.get("effort"))
66                    .and_then(Value::as_str)
67                    .map(ToOwned::to_owned),
68                raw: body.get("reasoning").cloned(),
69            },
70            sampling: SamplingParams {
71                temperature: body.get("temperature").and_then(Value::as_f64),
72                top_p: body.get("top_p").and_then(Value::as_f64),
73                top_k: None,
74            },
75            stream: body.get("stream").and_then(Value::as_bool).unwrap_or(false),
76            preservation: capture_request_preservation(
77                WireFormat::OpenAiResponses,
78                &Value::Object(body.clone()),
79                policy,
80            ),
81            ..LlmRequest::default()
82        };
83        if let Some(instructions) = body.get("instructions").and_then(Value::as_str)
84            && !instructions.is_empty()
85        {
86            request.instructions.push(crate::llm::InstructionBlock {
87                role: Role::System,
88                content: vec![ContentBlock::Text {
89                    text: instructions.to_string(),
90                }],
91            });
92        }
93        // With `previous_response_id`, the provider holds the earlier turns, so a tool output
94        // may answer a call that is not in this body.
95        let stored_state = body
96            .get("previous_response_id")
97            .and_then(Value::as_str)
98            .is_some_and(|id| !id.is_empty());
99        let mut custom_call_outputs = Vec::new();
100        request.messages = decode_responses_input(
101            body.get("input").unwrap_or(&Value::String(String::new())),
102            stored_state,
103            &mut custom_call_outputs,
104            &mut diagnostics,
105            policy,
106        )?;
107        let mut tool_namespaces = Map::new();
108        let mut custom_tools = Map::new();
109        request.tools =
110            decode_responses_tools(body.get("tools"), &mut tool_namespaces, &mut custom_tools)
111                .into_iter()
112                .map(Tool::from)
113                .collect();
114        // Responses-lite clients (Codex with a GPT-5 model) carry the tool definitions inside
115        // `input` as an `additional_tools` developer item instead of top-level `tools`. Those
116        // definitions are the request's tools; the item itself is kept verbatim so the request
117        // can be re-emitted in the shape the client used.
118        let mut additional_tools = Vec::new();
119        if let Some(items) = body.get("input").and_then(Value::as_array) {
120            for item in items {
121                if let Some(item) = item.as_object()
122                    && item.get("type").and_then(Value::as_str) == Some("additional_tools")
123                    && let Some(tools) = item.get("tools").and_then(Value::as_array)
124                {
125                    request.tools.extend(
126                        decode_responses_tools(
127                            Some(&Value::Array(tools.clone())),
128                            &mut tool_namespaces,
129                            &mut custom_tools,
130                        )
131                        .into_iter()
132                        .map(Tool::from),
133                    );
134                    additional_tools.extend(tools.iter().cloned());
135                }
136            }
137        }
138        request.tool_choice = body
139            .get("tool_choice")
140            .and_then(decode_responses_tool_choice);
141        request.extensions.fields = provider_extensions(
142            body,
143            &[
144                "model",
145                "instructions",
146                "input",
147                "tools",
148                "tool_choice",
149                "max_output_tokens",
150                "text",
151                "reasoning",
152                "temperature",
153                "top_p",
154                "stream",
155            ],
156        );
157        crate::codex_namespaces::attach_tool_namespaces(&mut request.extensions, tool_namespaces);
158        crate::codex_custom_tools::attach_custom_tools(&mut request.extensions, custom_tools);
159        crate::codex_custom_tools::attach_custom_call_outputs(
160            &mut request.extensions,
161            custom_call_outputs,
162        );
163        crate::codex_custom_tools::attach_additional_tools(
164            &mut request.extensions,
165            additional_tools,
166        );
167        Ok(DecodedRequest {
168            request,
169            diagnostics,
170        })
171    }
172
173    fn encode_request(
174        &self,
175        request: &LlmRequest,
176        policy: &TranslationPolicy,
177    ) -> Result<EncodedRequest> {
178        if let Some(body) =
179            exact_preserved_request(&request.preservation, WireFormat::OpenAiResponses, policy)
180        {
181            return Ok(EncodedRequest {
182                body,
183                diagnostics: Vec::new(),
184            });
185        }
186        let mut diagnostics = Vec::new();
187        validate_request_capabilities(request, &mut diagnostics, policy)?;
188        let mut body = Map::new();
189        if let Some(model) = &request.model {
190            body.insert("model".to_string(), Value::String(model.clone()));
191        }
192        let instructions = request
193            .instructions
194            .iter()
195            .flat_map(|instruction| instruction.content.iter())
196            .filter_map(|block| match block {
197                ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
198                _ => None,
199            })
200            .collect::<Vec<_>>()
201            .join("\n\n");
202        if !instructions.is_empty() {
203            body.insert("instructions".to_string(), Value::String(instructions));
204        }
205        body.insert(
206            "input".to_string(),
207            encode_responses_input(
208                &request.messages,
209                &mut diagnostics,
210                policy,
211                crate::codex_namespaces::tool_namespaces(&request.extensions),
212                &crate::codex_custom_tools::custom_tool_names(&request.extensions),
213                crate::codex_custom_tools::custom_call_outputs(&request.extensions),
214            )?,
215        );
216        if let Some(additional) = crate::codex_custom_tools::additional_tools(&request.extensions) {
217            // A Responses-lite request carried its tools inside `input`; give them back the same
218            // way, verbatim, and leave top-level `tools` absent as the client did.
219            let item = json!({
220                "type": "additional_tools",
221                "role": "developer",
222                "tools": additional,
223            });
224            match body.get_mut("input") {
225                Some(Value::Array(input)) => input.insert(0, item),
226                _ => body
227                    .insert("input".to_string(), Value::Array(vec![item]))
228                    .map_or((), |_| ()),
229            }
230        } else if !request.tools.is_empty() {
231            body.insert(
232                "tools".to_string(),
233                encode_responses_tools(
234                    &request.tools,
235                    crate::codex_namespaces::tool_namespaces(&request.extensions),
236                    crate::codex_custom_tools::custom_tools(&request.extensions),
237                )?,
238            );
239        }
240        if let Some(choice) = &request.tool_choice
241            && let Some(choice) = encode_responses_tool_choice(
242                choice,
243                crate::codex_namespaces::tool_namespaces(&request.extensions),
244            )
245        {
246            body.insert("tool_choice".to_string(), choice);
247        }
248        if let Some(max_output_tokens) = request.output.max_output_tokens {
249            body.insert("max_output_tokens".to_string(), json!(max_output_tokens));
250        }
251        if let Some(response_format) =
252            encode_openai_format(&request.output, &mut diagnostics, policy)?
253        {
254            body.insert(
255                "text".to_string(),
256                json!({"format": encode_responses_text_format(&response_format)}),
257            );
258        }
259        // An Anthropic request's raw reasoning is its `thinking` object, which Responses
260        // does not accept. Keep only whether thinking is disabled.
261        let mut reasoning = if is_anthropic_request(request) {
262            let mut reasoning = Map::new();
263            if request
264                .reasoning
265                .raw
266                .as_ref()
267                .and_then(|thinking| thinking.get("type"))
268                .and_then(Value::as_str)
269                == Some("disabled")
270            {
271                reasoning.insert("effort".to_string(), json!("none"));
272            }
273            reasoning
274        } else {
275            request
276                .reasoning
277                .raw
278                .as_ref()
279                .and_then(Value::as_object)
280                .cloned()
281                .unwrap_or_default()
282        };
283        if let Some(effort) = &request.reasoning.effort {
284            reasoning.insert("effort".to_string(), json!(effort));
285        }
286        if !reasoning.is_empty() {
287            body.insert("reasoning".to_string(), Value::Object(reasoning));
288        }
289        if let Some(value) = request.sampling.temperature {
290            body.insert("temperature".to_string(), json!(value));
291        }
292        if let Some(value) = request.sampling.top_p {
293            body.insert("top_p".to_string(), json!(value));
294        }
295        if request.stream {
296            body.insert("stream".to_string(), Value::Bool(true));
297        }
298        copy_responses_request_extensions(&mut body, &request.extensions.fields);
299
300        let body = embed_preservation(Value::Object(body), &request.preservation, policy);
301        Ok(EncodedRequest { body, diagnostics })
302    }
303
304    fn decode_response(&self, body: &Value, policy: &TranslationPolicy) -> Result<DecodedResponse> {
305        let body = crate::util::object(body, "$")?;
306        // Providers can return HTTP 200 when generation fails. Check `status`
307        // before treating an empty `output` as a completed turn.
308        if body.get("status").and_then(Value::as_str) == Some("failed") {
309            return Err(TranslationError::UpstreamFailure {
310                error: body
311                    .get("error")
312                    .filter(|error| error.is_object())
313                    .cloned()
314                    .unwrap_or_else(|| {
315                        json!({ "message": "provider reported status \"failed\" without error details" })
316                    }),
317            });
318        }
319        let mut diagnostics = Vec::new();
320        let mut content = Vec::new();
321        let mut role = Role::Assistant;
322        let mut stop_reason = None;
323        let mut has_output = false;
324        let mut url_citations = Vec::new();
325        let mut text_offset = 0;
326        if let Some(items) = body.get("output").and_then(Value::as_array) {
327            for item in items {
328                if let Some(output) = decode_responses_output_item(item, &mut diagnostics, policy)?
329                {
330                    has_output = true;
331                    role = output.role;
332                    for mut citation in output.url_citations {
333                        citation.start_index += text_offset;
334                        citation.end_index += text_offset;
335                        url_citations.push(citation);
336                    }
337                    text_offset += output
338                        .content
339                        .iter()
340                        .map(|block| match block {
341                            ContentBlock::Text { text } | ContentBlock::Refusal { text } => {
342                                text.chars().count()
343                            }
344                            _ => 0,
345                        })
346                        .sum::<usize>();
347                    content.extend(output.content);
348                    if output.stop_reason.is_some() {
349                        stop_reason = output.stop_reason;
350                    }
351                }
352            }
353        }
354        // The truncation signal is on the response, not the output items.
355        if body.get("status").and_then(Value::as_str) == Some("incomplete")
356            && body
357                .get("incomplete_details")
358                .and_then(|details| details.get("reason"))
359                .and_then(Value::as_str)
360                == Some("max_output_tokens")
361        {
362            stop_reason = Some(StopReason::MaxTokens);
363        }
364        let outputs = vec![if has_output {
365            ResponseOutput {
366                url_citations,
367                role,
368                content,
369                stop_reason,
370            }
371        } else {
372            ResponseOutput {
373                url_citations: Vec::new(),
374                role: Role::Assistant,
375                content: vec![ContentBlock::Text {
376                    text: String::new(),
377                }],
378                stop_reason: Some(StopReason::EndTurn),
379            }
380        }];
381        let response = AggLlmResponse {
382            id: body
383                .get("id")
384                .and_then(Value::as_str)
385                .map(ToOwned::to_owned),
386            model: body
387                .get("model")
388                .and_then(Value::as_str)
389                .map(ToOwned::to_owned),
390            outputs,
391            usage: decode_responses_usage(body.get("usage")),
392            extensions: ProviderExtensions {
393                fields: provider_extensions(
394                    body,
395                    &["id", "model", "object", "output", "usage", "status"],
396                ),
397            },
398            preservation: capture_response_preservation(
399                WireFormat::OpenAiResponses,
400                &Value::Object(body.clone()),
401                policy,
402            ),
403        };
404        Ok(DecodedResponse {
405            response,
406            diagnostics,
407        })
408    }
409
410    fn encode_response(
411        &self,
412        response: &AggLlmResponse,
413        _policy: &TranslationPolicy,
414    ) -> Result<EncodedResponse> {
415        if let Some(body) =
416            exact_preserved_response(&response.preservation, WireFormat::OpenAiResponses, _policy)
417        {
418            return Ok(EncodedResponse {
419                body,
420                diagnostics: Vec::new(),
421            });
422        }
423        let is_truncated = matches!(
424            response
425                .first_output()
426                .and_then(|output| output.stop_reason),
427            Some(StopReason::MaxTokens)
428        );
429        let status = if is_truncated {
430            "incomplete"
431        } else {
432            "completed"
433        };
434        let incomplete_details = is_truncated.then(|| json!({ "reason": "max_output_tokens" }));
435        Ok(EncodedResponse {
436            body: embed_preservation(
437                json!({
438                    "id": response.id.clone().unwrap_or_else(|| "resp_switchyard".to_string()),
439                    "object": "response",
440                    "created_at": 0,
441                    "model": response.model.clone().unwrap_or_else(|| "unknown".to_string()),
442                    "status": status,
443                    "incomplete_details": incomplete_details,
444                    "output": encode_responses_output(&response.outputs),
445                    "usage": encode_responses_usage(&response.usage),
446                    "parallel_tool_calls": true,
447                    "tool_choice": "auto",
448                    "tools": [],
449                }),
450                &response.preservation,
451                _policy,
452            ),
453            diagnostics: Vec::new(),
454        })
455    }
456}
457
458/// Decodes Responses `input` into ordered normalized messages.
459fn decode_responses_input(
460    value: &Value,
461    stored_state: bool,
462    custom_call_outputs: &mut Vec<String>,
463    diagnostics: &mut Vec<TranslationDiagnostic>,
464    policy: &TranslationPolicy,
465) -> Result<Vec<Message>> {
466    match value {
467        Value::String(text) => Ok(vec![Message::text(Role::User, text)]),
468        Value::Array(items) => {
469            let mut messages = Vec::new();
470            let mut pending_tool_calls = Vec::new();
471            let mut pending_tool_outputs = Vec::new();
472            let mut deferred_messages = Vec::new();
473            let mut pending_reasoning: Vec<ContentBlock> = Vec::new();
474            for (index, item) in items.iter().enumerate() {
475                let Some(item) = item.as_object() else {
476                    push_lossy(
477                        diagnostics,
478                        policy,
479                        format!("Responses input item {index} is not an object"),
480                    )?;
481                    continue;
482                };
483                let item_type = match item.get("type") {
484                    Some(Value::String(item_type)) => Some(item_type.as_str()),
485                    Some(_) => {
486                        return Err(TranslationError::InvalidType {
487                            path: format!("$.input[{index}].type"),
488                            expected: "a string",
489                        });
490                    }
491                    None => None,
492                };
493                let is_message = item_type == Some("message")
494                    || (item_type.is_none()
495                        && item.contains_key("role")
496                        && item.contains_key("content"));
497                match item_type {
498                    _ if is_message => {
499                        let role = request_role_from_responses(
500                            item.get("role").and_then(Value::as_str),
501                            &format!("$.input[{index}].role"),
502                        )?;
503                        let content_value = item.get("content").unwrap_or(&Value::Null);
504                        if is_responses_native_input_item(content_value) {
505                            return Err(TranslationError::InvalidValue {
506                                path: format!("$.input[{index}].content.type"),
507                                message: "native Responses items must be top-level input items"
508                                    .to_string(),
509                            });
510                        }
511                        if let Some(content_index) = content_value.as_array().and_then(|blocks| {
512                            blocks.iter().position(is_responses_native_input_item)
513                        }) {
514                            return Err(TranslationError::InvalidValue {
515                                path: format!("$.input[{index}].content[{content_index}].type"),
516                                message: "native Responses items must be top-level input items"
517                                    .to_string(),
518                            });
519                        }
520                        let mut content = decode_responses_content(content_value);
521                        // Inline instructions wait behind an open tool block or pending
522                        // reasoning. They never split tool calls from their outputs, and
523                        // reasoning keeps its place before them.
524                        if matches!(role, Role::System | Role::Developer)
525                            && !(pending_tool_calls.is_empty()
526                                && pending_tool_outputs.is_empty()
527                                && pending_reasoning.is_empty())
528                        {
529                            deferred_messages.push(Message { role, content });
530                            continue;
531                        }
532                        // Reasoning that precedes an assistant message belongs to
533                        // that turn; fold it in so it never surfaces as its own
534                        // (empty-looking) assistant message downstream.
535                        if role == Role::Assistant
536                            && pending_tool_calls.is_empty()
537                            && !pending_reasoning.is_empty()
538                        {
539                            let mut merged = std::mem::take(&mut pending_reasoning);
540                            merged.append(&mut content);
541                            content = merged;
542                            // Instructions held behind the reasoning came before this turn.
543                            if pending_tool_outputs.is_empty() {
544                                messages.append(&mut deferred_messages);
545                            }
546                        }
547                        if role != Role::Assistant {
548                            flush_unattached_responses_reasoning(
549                                &mut messages,
550                                &mut pending_tool_calls,
551                                &mut pending_tool_outputs,
552                                &mut deferred_messages,
553                                &mut pending_reasoning,
554                            );
555                        }
556                        push_responses_non_tool_message(
557                            &mut messages,
558                            &mut pending_tool_calls,
559                            &mut pending_tool_outputs,
560                            &mut deferred_messages,
561                            &mut pending_reasoning,
562                            Message { role, content },
563                        );
564                    }
565                    Some("reasoning") => {
566                        // A reasoning item after a completed tool block opens the
567                        // next assistant turn: flush the finished block so this
568                        // reasoning attaches to the turn that produced it.
569                        if !pending_tool_outputs.is_empty() {
570                            flush_responses_tool_block(
571                                &mut messages,
572                                &mut pending_tool_calls,
573                                &mut pending_tool_outputs,
574                                &mut deferred_messages,
575                                &mut pending_reasoning,
576                            );
577                        }
578                        pending_reasoning.extend(decode_responses_reasoning_item(item));
579                    }
580                    Some("function_call") => {
581                        if !pending_tool_outputs.is_empty() {
582                            flush_responses_tool_block(
583                                &mut messages,
584                                &mut pending_tool_calls,
585                                &mut pending_tool_outputs,
586                                &mut deferred_messages,
587                                &mut pending_reasoning,
588                            );
589                        }
590                        let id = item
591                            .get("call_id")
592                            .and_then(Value::as_str)
593                            .filter(|id| !id.is_empty())
594                            .map(ToOwned::to_owned)
595                            .unwrap_or_else(|| match &policy.deterministic_ids {
596                                DeterministicIdPolicy::GenerateStable { prefix } => {
597                                    stable_id(prefix, index + 1)
598                                }
599                                DeterministicIdPolicy::Preserve => String::new(),
600                            });
601                        // History has to spell a tool the way its definition
602                        // does, or the transcript teaches the model a name the
603                        // upstream was never offered.
604                        let name = item
605                            .get("name")
606                            .and_then(Value::as_str)
607                            .unwrap_or_default()
608                            .to_string();
609                        let name = match item
610                            .get("namespace")
611                            .and_then(Value::as_str)
612                            .filter(|namespace| !namespace.is_empty())
613                        {
614                            Some(namespace) => {
615                                crate::codex_namespaces::qualified_tool_name(namespace, &name)
616                            }
617                            None => name,
618                        };
619                        pending_tool_calls.push(ToolCall {
620                            id,
621                            name,
622                            arguments: item.get("arguments").cloned().unwrap_or_else(|| json!({})),
623                        });
624                    }
625                    kind @ (Some("function_call_output") | Some("custom_tool_call_output")) => {
626                        let tool_call_id = item
627                            .get("call_id")
628                            .and_then(Value::as_str)
629                            .unwrap_or_default()
630                            .to_string();
631                        // A structured output keeps its typed text, image, and file parts;
632                        // any other shape rides through as one text block.
633                        let content = match item.get("output") {
634                            Some(output @ Value::Array(_)) => decode_responses_content(output),
635                            output => vec![ContentBlock::Text {
636                                text: output.map(json_string).unwrap_or_default(),
637                            }],
638                        };
639                        // An output whose call is not in this body answers a call the provider
640                        // holds behind `previous_response_id`; it stays a tool result so routing
641                        // sees a tool continuation, not a new user turn. Without stored state
642                        // the request is malformed, and the output becomes readable user text.
643                        let answers_pending_call = pending_tool_calls
644                            .iter()
645                            .any(|call| call.id == tool_call_id);
646                        if !answers_pending_call && !stored_state {
647                            let output_text = text_from_blocks(&content, " ");
648                            let message = Message::text(
649                                Role::User,
650                                format!("Tool result {tool_call_id}: {output_text}"),
651                            );
652                            push_responses_non_tool_message(
653                                &mut messages,
654                                &mut pending_tool_calls,
655                                &mut pending_tool_outputs,
656                                &mut deferred_messages,
657                                &mut pending_reasoning,
658                                message,
659                            );
660                            continue;
661                        }
662                        // The encoder types an output by the call it answers; a stored custom
663                        // output has no call in this body, so its type is carried separately.
664                        if !answers_pending_call && kind == Some("custom_tool_call_output") {
665                            custom_call_outputs.push(tool_call_id.clone());
666                        }
667                        pending_tool_outputs.push(ToolResult {
668                            tool_call_id,
669                            content,
670                            is_error: None,
671                        });
672                    }
673                    Some("custom_tool_call") => {
674                        // A freeform call carries a raw `input` string; it rides through the IR
675                        // as the single `input` argument of a function-style call.
676                        if !pending_tool_outputs.is_empty() {
677                            flush_responses_tool_block(
678                                &mut messages,
679                                &mut pending_tool_calls,
680                                &mut pending_tool_outputs,
681                                &mut deferred_messages,
682                                &mut pending_reasoning,
683                            );
684                        }
685                        let id = item
686                            .get("call_id")
687                            .and_then(Value::as_str)
688                            .filter(|id| !id.is_empty())
689                            .map(ToOwned::to_owned)
690                            .unwrap_or_else(|| match &policy.deterministic_ids {
691                                DeterministicIdPolicy::GenerateStable { prefix } => {
692                                    stable_id(prefix, index + 1)
693                                }
694                                DeterministicIdPolicy::Preserve => String::new(),
695                            });
696                        let name = item
697                            .get("name")
698                            .and_then(Value::as_str)
699                            .unwrap_or_default()
700                            .to_string();
701                        let input = item
702                            .get("input")
703                            .and_then(Value::as_str)
704                            .unwrap_or_default()
705                            .to_string();
706                        pending_tool_calls.push(ToolCall {
707                            id,
708                            name,
709                            arguments: json!({crate::codex_custom_tools::INPUT_ARGUMENT: input}),
710                        });
711                    }
712                    None => {
713                        return Err(TranslationError::InvalidValue {
714                            path: format!("$.input[{index}].type"),
715                            message: "missing type discriminator on a non-message input item"
716                                .to_string(),
717                        });
718                    }
719                    // Tool definitions, not conversation; decoded separately by the request
720                    // decoder and re-emitted in place by the request encoder.
721                    Some("additional_tools") => {}
722                    _ => {
723                        let message = Message {
724                            role: match item_type {
725                                Some(
726                                    "shell_call"
727                                    | "apply_patch_call"
728                                    | "computer_call"
729                                    | "image_generation_call",
730                                ) => Role::Assistant,
731                                Some(
732                                    "shell_call_output"
733                                    | "apply_patch_call_output"
734                                    | "computer_call_output",
735                                ) => Role::Tool,
736                                _ => Role::User,
737                            },
738                            content: vec![ContentBlock::Unknown {
739                                provider: WireFormat::OpenAiResponses.into(),
740                                raw: Value::Object(item.clone()),
741                            }],
742                        };
743                        flush_unattached_responses_reasoning(
744                            &mut messages,
745                            &mut pending_tool_calls,
746                            &mut pending_tool_outputs,
747                            &mut deferred_messages,
748                            &mut pending_reasoning,
749                        );
750                        push_responses_non_tool_message(
751                            &mut messages,
752                            &mut pending_tool_calls,
753                            &mut pending_tool_outputs,
754                            &mut deferred_messages,
755                            &mut pending_reasoning,
756                            message,
757                        );
758                    }
759                }
760            }
761            if !pending_tool_calls.is_empty() || !pending_tool_outputs.is_empty() {
762                flush_responses_tool_block(
763                    &mut messages,
764                    &mut pending_tool_calls,
765                    &mut pending_tool_outputs,
766                    &mut deferred_messages,
767                    &mut pending_reasoning,
768                );
769            }
770            // Trailing reasoning with no assistant turn to join keeps the
771            // historical standalone-message shape.
772            if !pending_reasoning.is_empty() {
773                messages.push(Message {
774                    role: Role::Assistant,
775                    content: pending_reasoning,
776                });
777            }
778            messages.append(&mut deferred_messages);
779            Ok(messages)
780        }
781        _ => Err(TranslationError::InvalidType {
782            path: "$.input".to_string(),
783            expected: "string or array",
784        }),
785    }
786}
787
788/// Places non-tool input items without breaking Responses tool-call adjacency.
789fn push_responses_non_tool_message(
790    messages: &mut Vec<Message>,
791    pending_tool_calls: &mut Vec<ToolCall>,
792    pending_tool_outputs: &mut Vec<ToolResult>,
793    deferred_messages: &mut Vec<Message>,
794    pending_reasoning: &mut Vec<ContentBlock>,
795    message: Message,
796) {
797    if !pending_tool_calls.is_empty() && pending_tool_outputs.is_empty() {
798        deferred_messages.push(message);
799        return;
800    }
801    if !pending_tool_calls.is_empty() || !pending_tool_outputs.is_empty() {
802        flush_responses_tool_block(
803            messages,
804            pending_tool_calls,
805            pending_tool_outputs,
806            deferred_messages,
807            pending_reasoning,
808        );
809    }
810    messages.push(message);
811}
812
813/// Emits reasoning that cannot join an assistant turn as a standalone assistant
814/// message at its original position. While tool calls are pending, the
815/// reasoning belongs to the in-flight turn and stays pending for the flush.
816fn flush_unattached_responses_reasoning(
817    messages: &mut Vec<Message>,
818    pending_tool_calls: &mut Vec<ToolCall>,
819    pending_tool_outputs: &mut Vec<ToolResult>,
820    deferred_messages: &mut Vec<Message>,
821    pending_reasoning: &mut Vec<ContentBlock>,
822) {
823    if pending_reasoning.is_empty() || !pending_tool_calls.is_empty() {
824        return;
825    }
826    let message = Message {
827        role: Role::Assistant,
828        content: std::mem::take(pending_reasoning),
829    };
830    push_responses_non_tool_message(
831        messages,
832        pending_tool_calls,
833        pending_tool_outputs,
834        deferred_messages,
835        pending_reasoning,
836        message,
837    );
838    // Instructions held behind the reasoning follow it.
839    messages.append(deferred_messages);
840}
841
842/// Flushes pending function calls and matching outputs while preserving adjacency.
843/// Pending reasoning rides in the same assistant message as the tool calls so a
844/// Codex reasoning + function_call turn stays one assistant turn downstream.
845fn flush_responses_tool_block(
846    messages: &mut Vec<Message>,
847    pending_tool_calls: &mut Vec<ToolCall>,
848    pending_tool_outputs: &mut Vec<ToolResult>,
849    deferred_messages: &mut Vec<Message>,
850    pending_reasoning: &mut Vec<ContentBlock>,
851) {
852    if !pending_tool_calls.is_empty() {
853        let mut content = std::mem::take(pending_reasoning);
854        content.extend(
855            std::mem::take(pending_tool_calls)
856                .into_iter()
857                .map(ContentBlock::ToolCall),
858        );
859        messages.push(Message {
860            role: Role::Assistant,
861            content,
862        });
863    }
864
865    for output in std::mem::take(pending_tool_outputs) {
866        messages.push(Message {
867            role: Role::User,
868            content: vec![ContentBlock::ToolResult(output)],
869        });
870    }
871
872    messages.append(deferred_messages);
873}
874
875// Decodes a Responses reasoning item into private reasoning IR content.
876fn decode_responses_reasoning_item(item: &Map<String, Value>) -> Vec<ContentBlock> {
877    if let Some((text, signature)) = item
878        .get("encrypted_content")
879        .and_then(Value::as_str)
880        .and_then(super::decode_anthropic_thinking)
881    {
882        return vec![ContentBlock::Reasoning {
883            text,
884            signature: Some(signature),
885            details: Vec::new(),
886        }];
887    }
888    let mut parts = Vec::new();
889    collect_responses_reasoning_text(item.get("content"), &mut parts);
890    collect_responses_reasoning_text(item.get("summary"), &mut parts);
891    if let Some(text) = item.get("text").and_then(Value::as_str) {
892        parts.push(text.to_string());
893    }
894    // Encrypted reasoning must be replayed under the provider-issued item id;
895    // retain the original item only when it carries that opaque payload.
896    let details = if item.get("encrypted_content").is_some() {
897        vec![Value::Object(item.clone())]
898    } else {
899        Vec::new()
900    };
901    vec![ContentBlock::Reasoning {
902        text: parts.join("\n"),
903        signature: None,
904        details,
905    }]
906}
907
908// Decodes Responses content arrays or strings into normalized content blocks.
909fn decode_responses_content(value: &Value) -> Vec<ContentBlock> {
910    match value {
911        Value::String(text) => vec![ContentBlock::Text { text: text.clone() }],
912        Value::Array(blocks) => {
913            let mut out = Vec::new();
914            for block in blocks {
915                let Some(block) = block.as_object() else {
916                    continue;
917                };
918                match block.get("type").and_then(Value::as_str) {
919                    Some("input_text") | Some("output_text") | Some("text") => {
920                        out.push(ContentBlock::Text {
921                            text: block
922                                .get("text")
923                                .and_then(Value::as_str)
924                                .unwrap_or_default()
925                                .to_string(),
926                        });
927                    }
928                    Some("refusal") => out.push(ContentBlock::Refusal {
929                        text: block
930                            .get("refusal")
931                            .and_then(Value::as_str)
932                            .unwrap_or_default()
933                            .to_string(),
934                    }),
935                    Some("reasoning_text") | Some("summary_text") => {
936                        out.push(ContentBlock::Reasoning {
937                            text: block
938                                .get("text")
939                                .and_then(Value::as_str)
940                                .unwrap_or_default()
941                                .to_string(),
942                            signature: None,
943                            details: Vec::new(),
944                        });
945                    }
946                    Some("input_image") => {
947                        if let Some(source) = decode_image_source(block) {
948                            out.push(ContentBlock::Image { source });
949                        }
950                    }
951                    Some("input_audio") => out.push(ContentBlock::Audio {
952                        source: MediaSource::Raw(Value::Object(block.clone())),
953                    }),
954                    Some("input_file") => out.push(ContentBlock::File {
955                        source: decode_file_source(block),
956                    }),
957                    _ => out.push(ContentBlock::Unknown {
958                        provider: WireFormat::OpenAiResponses.into(),
959                        raw: Value::Object(block.clone()),
960                    }),
961                }
962            }
963            if out.is_empty() {
964                out.push(ContentBlock::Text {
965                    text: String::new(),
966                });
967            }
968            out
969        }
970        Value::Null => vec![ContentBlock::Text {
971            text: String::new(),
972        }],
973        other => vec![ContentBlock::Text {
974            text: string_value(other).unwrap_or_default(),
975        }],
976    }
977}
978
979// Decodes Responses role strings into normalized roles. Used for decoding
980// provider *responses*, which stay lenient — an unexpected role from upstream
981// is coerced to `user` rather than failing the whole response.
982fn role_from_responses(role: Option<&str>) -> Role {
983    match role {
984        Some("assistant") => Role::Assistant,
985        Some("system") => Role::System,
986        Some("developer") => Role::Developer,
987        _ => Role::User,
988    }
989}
990
991// Decodes a Responses *request* message role, enforcing the provider contract.
992//
993// Unlike `role_from_responses` (used for provider responses), request decoding
994// rejects an unknown role such as "api" with [`TranslationError::InvalidValue`]
995// so the router surfaces the same `invalid_value` error the provider would,
996// instead of silently coercing it to `user`. A missing role and
997// known-but-unmapped roles keep their historical mapping to `user`.
998fn request_role_from_responses(role: Option<&str>, path: &str) -> Result<Role> {
999    match role {
1000        Some("assistant") => Ok(Role::Assistant),
1001        Some("system") => Ok(Role::System),
1002        Some("developer") => Ok(Role::Developer),
1003        None => Ok(Role::User),
1004        Some(other) if is_known_role_name(other) => Ok(Role::User),
1005        Some(other) => Err(TranslationError::unsupported_role(path, other)),
1006    }
1007}
1008
1009// Decodes Responses tool shapes, including Codex-style tool entries.
1010// Decodes Responses tool shapes, including Codex-style tool entries.
1011//
1012// Codex groups tools into non-standard ``namespace`` containers that
1013// OpenAI-compatible upstreams do not accept. Each child is exposed under a
1014// `<namespace>__<tool>` name, and `namespaces` records the mapping so the
1015// response can split it back apart.
1016fn decode_responses_tools(
1017    value: Option<&Value>,
1018    namespaces: &mut Map<String, Value>,
1019    custom_tools: &mut Map<String, Value>,
1020) -> Vec<ToolDefinition> {
1021    let Some(tools) = value.and_then(Value::as_array) else {
1022        return Vec::new();
1023    };
1024    let mut out = Vec::new();
1025    for tool in tools {
1026        let Some(tool) = tool.as_object() else {
1027            continue;
1028        };
1029        if tool.get("type").and_then(Value::as_str) == Some("namespace") {
1030            // An unnamed container carries nothing to dispatch on, so its
1031            // children stay bare rather than gaining an empty prefix.
1032            let container = tool
1033                .get("name")
1034                .and_then(Value::as_str)
1035                .filter(|name| !name.is_empty());
1036            let description = tool.get("description").and_then(Value::as_str);
1037            for mut child in decode_responses_tools(tool.get("tools"), namespaces, custom_tools) {
1038                // A nested container already qualified its own children, and the
1039                // innermost name is the one that identifies the tool.
1040                let already_qualified = namespaces.contains_key(&child.name);
1041                if let Some(container) = container
1042                    && !already_qualified
1043                {
1044                    let qualified =
1045                        crate::codex_namespaces::qualified_tool_name(container, &child.name);
1046                    crate::codex_namespaces::record_tool_namespace(
1047                        namespaces,
1048                        &qualified,
1049                        container,
1050                        description,
1051                    );
1052                    child.name = qualified;
1053                }
1054                out.push(child);
1055            }
1056        } else if tool.get("type").and_then(Value::as_str) == Some("custom") {
1057            // A freeform tool takes a raw string, not JSON arguments. The IR sees it as a
1058            // function with a single `input` argument; the verbatim definition is kept so a
1059            // Responses upstream still receives the freeform tool. The definition is keyed by
1060            // the tool's own name: Codex defines its freeform tools at the top level, so a
1061            // custom tool nested in a namespace container (which would be renamed above) is
1062            // not restored verbatim and goes upstream as the equivalent function tool.
1063            if let Some(name) = tool
1064                .get("name")
1065                .and_then(Value::as_str)
1066                .filter(|name| !name.is_empty())
1067            {
1068                custom_tools.insert(name.to_string(), Value::Object(tool.clone()));
1069                out.push(ToolDefinition {
1070                    name: name.to_string(),
1071                    description: tool
1072                        .get("description")
1073                        .and_then(Value::as_str)
1074                        .map(ToOwned::to_owned),
1075                    parameters: crate::codex_custom_tools::input_schema(),
1076                    strict: None,
1077                });
1078            }
1079        } else if tool.get("type").and_then(Value::as_str) == Some("function") {
1080            if let Some(function) = tool.get("function").and_then(Value::as_object) {
1081                if let Some(name) = function.get("name").and_then(Value::as_str)
1082                    && !name.is_empty()
1083                {
1084                    out.push(ToolDefinition {
1085                        name: name.to_string(),
1086                        description: function
1087                            .get("description")
1088                            .and_then(Value::as_str)
1089                            .map(ToOwned::to_owned),
1090                        parameters: function
1091                            .get("parameters")
1092                            .cloned()
1093                            .unwrap_or_else(|| json!({})),
1094                        strict: function.get("strict").and_then(Value::as_bool),
1095                    });
1096                }
1097            } else {
1098                if !push_responses_function_tool(&mut out, tool) {
1099                    push_responses_id_tool(&mut out, tool);
1100                }
1101            }
1102        } else if tool.get("type").is_none() && tool.contains_key("name") {
1103            push_responses_function_tool(&mut out, tool);
1104        } else {
1105            push_responses_id_tool(&mut out, tool);
1106        }
1107    }
1108    out
1109}
1110
1111// Adds an OpenAI-style function tool to the normalized tool list.
1112fn push_responses_function_tool(
1113    out: &mut Vec<ToolDefinition>,
1114    tool: &serde_json::Map<String, Value>,
1115) -> bool {
1116    let Some(name) = tool.get("name").and_then(Value::as_str) else {
1117        return false;
1118    };
1119    if name.is_empty() {
1120        return false;
1121    }
1122
1123    out.push(ToolDefinition {
1124        name: name.to_string(),
1125        description: tool
1126            .get("description")
1127            .and_then(Value::as_str)
1128            .map(ToOwned::to_owned),
1129        parameters: tool.get("parameters").cloned().unwrap_or_else(|| json!({})),
1130        strict: tool.get("strict").and_then(Value::as_bool),
1131    });
1132    true
1133}
1134
1135// Adds a Codex-style ID tool to the normalized tool list.
1136fn push_responses_id_tool(
1137    out: &mut Vec<ToolDefinition>,
1138    tool: &serde_json::Map<String, Value>,
1139) -> bool {
1140    let Some(id) = tool.get("id").and_then(Value::as_str) else {
1141        return false;
1142    };
1143    if id.is_empty() {
1144        return false;
1145    }
1146
1147    out.push(ToolDefinition {
1148        name: id.to_string(),
1149        description: tool
1150            .get("description")
1151            .and_then(Value::as_str)
1152            .map(ToOwned::to_owned),
1153        parameters: tool
1154            .get("inputSchema")
1155            .and_then(Value::as_object)
1156            .and_then(|schema| schema.get("jsonSchema"))
1157            .cloned()
1158            .unwrap_or_else(|| json!({})),
1159        strict: None,
1160    });
1161    true
1162}
1163
1164// Decodes Responses tool-choice values into normalized policy.
1165fn decode_responses_tool_choice(value: &Value) -> Option<ToolChoice> {
1166    match value {
1167        Value::String(text) if text == "auto" => Some(ToolChoice::Auto),
1168        Value::String(text) if text == "required" => Some(ToolChoice::Required),
1169        Value::String(text) if text == "none" => Some(ToolChoice::None),
1170        Value::Object(object) if object.get("type").and_then(Value::as_str) == Some("function") => {
1171            // A forced tool has to name the same thing its definition does, or
1172            // the upstream rejects a choice naming a tool it was never offered.
1173            object.get("name").and_then(Value::as_str).map(|name| {
1174                match object
1175                    .get("namespace")
1176                    .and_then(Value::as_str)
1177                    .filter(|namespace| !namespace.is_empty())
1178                {
1179                    Some(namespace) => ToolChoice::Tool {
1180                        name: crate::codex_namespaces::qualified_tool_name(namespace, name),
1181                    },
1182                    None => ToolChoice::Tool {
1183                        name: name.to_string(),
1184                    },
1185                }
1186            })
1187        }
1188        Value::Object(object)
1189            if object.get("type").and_then(Value::as_str) == Some("allowed_tools") =>
1190        {
1191            Some(ToolChoice::Raw(value.clone()))
1192        }
1193        Value::Object(_) => None,
1194        _ => Some(ToolChoice::Raw(value.clone())),
1195    }
1196}
1197
1198// Maps Responses text format options into Chat-compatible response_format JSON.
1199fn decode_responses_text_format(value: Option<&Value>) -> Option<Value> {
1200    let format = value
1201        .and_then(Value::as_object)
1202        .and_then(|object| object.get("format"))
1203        .and_then(Value::as_object)?;
1204
1205    match format.get("type").and_then(Value::as_str) {
1206        Some("json_schema") => {
1207            if let Some(json_schema) = format.get("json_schema").and_then(Value::as_object) {
1208                return Some(json!({
1209                    "type": "json_schema",
1210                    "json_schema": Value::Object(json_schema.clone()),
1211                }));
1212            }
1213
1214            let mut json_schema = Map::new();
1215            for field in ["name", "description", "schema", "strict"] {
1216                if let Some(value) = format.get(field) {
1217                    json_schema.insert(field.to_string(), value.clone());
1218                }
1219            }
1220            (!json_schema.is_empty()).then_some(json!({
1221                "type": "json_schema",
1222                "json_schema": Value::Object(json_schema),
1223            }))
1224        }
1225        Some("json_object") => Some(json!({"type": "json_object"})),
1226        Some("text") => Some(json!({"type": "text"})),
1227        _ => None,
1228    }
1229}
1230
1231// Flattens Chat-compatible JSON schema fields into the Responses text format shape.
1232fn encode_responses_text_format(response_format: &Value) -> Value {
1233    let Some(format) = response_format.as_object() else {
1234        return response_format.clone();
1235    };
1236    let Some(response_type) = format
1237        .get("type")
1238        .filter(|value| value.as_str() == Some("json_schema"))
1239    else {
1240        return response_format.clone();
1241    };
1242
1243    let Some(Value::Object(json_schema)) = format.get("json_schema") else {
1244        return response_format.clone();
1245    };
1246    if !matches!(json_schema.get("name"), Some(Value::String(_)))
1247        || !matches!(json_schema.get("schema"), Some(Value::Object(_)))
1248        || json_schema
1249            .get("description")
1250            .is_some_and(|value| !value.is_string())
1251        || json_schema
1252            .get("strict")
1253            .is_some_and(|value| !value.is_boolean())
1254    {
1255        return response_format.clone();
1256    }
1257
1258    let mut output = Map::new();
1259    output.insert("type".to_string(), response_type.clone());
1260    for field in ["name", "description", "schema", "strict"] {
1261        if let Some(value) = json_schema.get(field) {
1262            output.insert(field.to_string(), value.clone());
1263        }
1264    }
1265    Value::Object(output)
1266}
1267
1268// Encodes normalized messages into the Responses `input` shape.
1269fn encode_responses_input(
1270    messages: &[Message],
1271    diagnostics: &mut Vec<TranslationDiagnostic>,
1272    policy: &TranslationPolicy,
1273    namespaces: Option<&Map<String, Value>>,
1274    custom_tools: &std::collections::HashSet<String>,
1275    mut custom_call_ids: std::collections::HashSet<String>,
1276) -> Result<Value> {
1277    // Call ids of freeform tool calls emitted by this pass, seeded with the ids of stored-state
1278    // custom outputs. A tool result is typed by the call it answers, and Responses history
1279    // always lists the call before its output, so recording ids as calls are encoded is enough
1280    // to type the outputs that follow.
1281    //
1282    // A lone user text is still sent as an item list: the Codex backend rejects a string `input`.
1283    let mut encoded = Vec::new();
1284    // Some upstream translators (Kimi K3) resolve a tool output by an explicit
1285    // `name`, so pair every output with the name of the call it answers.
1286    let mut call_names: HashMap<&str, &str> = HashMap::new();
1287    for message in messages {
1288        for block in &message.content {
1289            if let ContentBlock::ToolCall(call) = block {
1290                call_names.insert(call.id.as_str(), call.name.as_str());
1291            }
1292        }
1293    }
1294    for message in messages {
1295        // The decoder carries each native top-level input item as one
1296        // provider-qualified block, so replay that block in place.
1297        if let [ContentBlock::Unknown { provider, raw }] = message.content.as_slice()
1298            && provider.as_str() == WireFormat::OpenAiResponses.as_str()
1299            && is_responses_native_input_item(raw)
1300        {
1301            encoded.push(raw.clone());
1302            continue;
1303        }
1304        // Anthropic-signed thinking cannot be sent as Responses input.
1305        let content = message
1306            .content
1307            .iter()
1308            .filter(|block| {
1309                !matches!(
1310                    block,
1311                    ContentBlock::Reasoning {
1312                        signature: Some(_),
1313                        ..
1314                    }
1315                )
1316            })
1317            .cloned()
1318            .collect::<Vec<_>>();
1319        if content.is_empty() {
1320            continue;
1321        }
1322        if content.iter().any(|block| {
1323            matches!(
1324                block,
1325                ContentBlock::ToolCall(_) | ContentBlock::ToolResult(_)
1326            )
1327        }) {
1328            let mut visible_content = Vec::new();
1329            for block in &content {
1330                if let Some(item) = encode_responses_special_input(
1331                    block,
1332                    namespaces,
1333                    &call_names,
1334                    custom_tools,
1335                    &mut custom_call_ids,
1336                    diagnostics,
1337                    policy,
1338                )? {
1339                    if !visible_content.is_empty() {
1340                        let content =
1341                            encode_responses_content(&visible_content, diagnostics, policy)?;
1342                        encoded.push(json!({
1343                            "type": "message",
1344                            "role": role_to_responses(message.role),
1345                            "content": content,
1346                        }));
1347                        visible_content.clear();
1348                    }
1349                    encoded.push(item);
1350                } else if !matches!(block, ContentBlock::Reasoning { .. }) {
1351                    visible_content.push(block.clone());
1352                }
1353            }
1354            if !visible_content.is_empty() {
1355                let content = encode_responses_content(&visible_content, diagnostics, policy)?;
1356                encoded.push(json!({
1357                    "type": "message",
1358                    "role": role_to_responses(message.role),
1359                    "content": content,
1360                }));
1361            }
1362            continue;
1363        }
1364        let mut visible_content = Vec::new();
1365        let mut emitted_special = false;
1366        let mut omitted_reasoning = false;
1367        for block in &content {
1368            if let Some(item) = encode_responses_special_input(
1369                block,
1370                namespaces,
1371                &call_names,
1372                custom_tools,
1373                &mut custom_call_ids,
1374                diagnostics,
1375                policy,
1376            )? {
1377                encoded.push(item);
1378                emitted_special = true;
1379            } else if !matches!(block, ContentBlock::Reasoning { .. }) {
1380                visible_content.push(block.clone());
1381            } else {
1382                omitted_reasoning = true;
1383            }
1384        }
1385        if !visible_content.is_empty() || (!emitted_special && !omitted_reasoning) {
1386            let content = encode_responses_content(&visible_content, diagnostics, policy)?;
1387            encoded.push(json!({
1388                "type": "message",
1389                "role": role_to_responses(message.role),
1390                "content": content,
1391            }));
1392        }
1393    }
1394    pair_tool_calls_with_outputs(&mut encoded);
1395    Ok(Value::Array(encoded))
1396}
1397
1398// Moves each tool output directly behind the call it answers. A turn with
1399// parallel tool calls otherwise serializes as call,call,output,output, which
1400// upstreams that pair by adjacency (Kimi K3) resolve to the wrong call.
1401fn pair_tool_calls_with_outputs(items: &mut Vec<Value>) {
1402    let call_id = |item: &Value, kind: &str| {
1403        (item.get("type").and_then(Value::as_str) == Some(kind))
1404            .then(|| {
1405                item.get("call_id")
1406                    .and_then(Value::as_str)
1407                    .map(str::to_owned)
1408            })
1409            .flatten()
1410    };
1411    let mut index = 0;
1412    while index < items.len() {
1413        // Freeform (`custom`) calls pair with `custom_tool_call_output` the same way.
1414        let (id, output_kind) = match call_id(&items[index], "function_call") {
1415            Some(id) => (id, "function_call_output"),
1416            None => match call_id(&items[index], "custom_tool_call") {
1417                Some(id) => (id, "custom_tool_call_output"),
1418                None => {
1419                    index += 1;
1420                    continue;
1421                }
1422            },
1423        };
1424        let output = items
1425            .iter()
1426            .skip(index + 1)
1427            .take_while(|item| item.get("type").and_then(Value::as_str) != Some("message"))
1428            .position(|item| call_id(item, output_kind).as_deref() == Some(&id))
1429            .map(|offset| index + 1 + offset);
1430        if let Some(output) = output
1431            && output != index + 1
1432        {
1433            let item = items.remove(output);
1434            items.insert(index + 1, item);
1435        }
1436        index += 1;
1437    }
1438}
1439
1440// Encodes IR blocks that Responses represents as top-level input items.
1441fn encode_responses_special_input(
1442    block: &ContentBlock,
1443    namespaces: Option<&Map<String, Value>>,
1444    call_names: &HashMap<&str, &str>,
1445    custom_tools: &std::collections::HashSet<String>,
1446    custom_call_ids: &mut std::collections::HashSet<String>,
1447    diagnostics: &mut Vec<TranslationDiagnostic>,
1448    policy: &TranslationPolicy,
1449) -> Result<Option<Value>> {
1450    Ok(match block {
1451        ContentBlock::Reasoning {
1452            text,
1453            signature: None,
1454            details,
1455        } => encode_responses_reasoning_input(text, details),
1456        ContentBlock::ToolCall(call) if custom_tools.contains(&call.name) => {
1457            // A freeform tool call replays as `custom_tool_call` with its raw input.
1458            custom_call_ids.insert(call.id.clone());
1459            Some(json!({
1460                "type": "custom_tool_call",
1461                "call_id": call.id,
1462                "name": call.name,
1463                "input": crate::codex_custom_tools::input_from_arguments(&call.arguments),
1464            }))
1465        }
1466        ContentBlock::ToolCall(call) => {
1467            // A Responses client dispatches on name plus namespace, so undo the
1468            // qualification this request applied for a flat upstream.
1469            let (name, namespace) = namespaces
1470                .and_then(|namespaces| {
1471                    crate::codex_namespaces::split_qualified_name(namespaces, &call.name)
1472                })
1473                .map_or((call.name.as_str(), None), |(name, namespace)| {
1474                    (name, Some(namespace))
1475                });
1476            let mut item = json!({
1477                "type": "function_call",
1478                "call_id": call.id,
1479                "name": name,
1480                "arguments": json_string(&call.arguments),
1481            });
1482            if let Some(namespace) = namespace {
1483                item["namespace"] = Value::String(namespace.to_string());
1484            }
1485            Some(item)
1486        }
1487        ContentBlock::ToolResult(result) => {
1488            // A result answering a freeform call replays as `custom_tool_call_output`; anything
1489            // else is a `function_call_output`.
1490            let mut item = json!({
1491                "type": if custom_call_ids.contains(&result.tool_call_id) {
1492                    "custom_tool_call_output"
1493                } else {
1494                    "function_call_output"
1495                },
1496                "call_id": result.tool_call_id,
1497                "output": encode_responses_tool_output(&result.content, diagnostics, policy)?,
1498            });
1499            // Carry the paired call's name, un-qualified to match the emitted
1500            // function_call, for upstreams that resolve outputs by name.
1501            if let Some(name) = call_names.get(result.tool_call_id.as_str()) {
1502                let name = namespaces
1503                    .and_then(|namespaces| {
1504                        crate::codex_namespaces::split_qualified_name(namespaces, name)
1505                    })
1506                    .map_or(*name, |(name, _)| name);
1507                item["name"] = Value::String(name.to_string());
1508            }
1509            Some(item)
1510        }
1511        _ => None,
1512    })
1513}
1514
1515// Encodes reasoning in the shape accepted for Responses input history. Response
1516// output items may carry `content`, but replayed input items must avoid it.
1517fn encode_responses_reasoning_input(text: &str, details: &[Value]) -> Option<Value> {
1518    for detail in details {
1519        let Some(detail) = detail.as_object() else {
1520            continue;
1521        };
1522        if detail.get("type").and_then(Value::as_str) != Some("reasoning") {
1523            continue;
1524        }
1525        let mut item = Map::new();
1526        item.insert("type".to_string(), Value::String("reasoning".to_string()));
1527        for field in ["id", "summary", "encrypted_content"] {
1528            if let Some(value) = detail.get(field) {
1529                item.insert(field.to_string(), value.clone());
1530            }
1531        }
1532        item.entry("summary".to_string())
1533            .or_insert_with(|| Value::Array(Vec::new()));
1534        return Some(Value::Object(item));
1535    }
1536
1537    if text.is_empty() {
1538        return None;
1539    }
1540    Some(json!({
1541        "type": "reasoning",
1542        "summary": [{"type": "summary_text", "text": text}],
1543    }))
1544}
1545
1546// Maps normalized roles back to Responses role strings.
1547fn role_to_responses(role: Role) -> &'static str {
1548    match role {
1549        Role::Assistant => "assistant",
1550        Role::System => "system",
1551        Role::Developer => "developer",
1552        Role::User | Role::Tool => "user",
1553    }
1554}
1555
1556// Encodes normalized content into Responses message content.
1557fn encode_responses_content(
1558    content: &[ContentBlock],
1559    diagnostics: &mut Vec<TranslationDiagnostic>,
1560    policy: &TranslationPolicy,
1561) -> Result<Value> {
1562    let has_non_text = content.iter().any(|block| {
1563        !matches!(
1564            block,
1565            ContentBlock::Text { .. }
1566                | ContentBlock::Refusal { .. }
1567                | ContentBlock::Reasoning { .. }
1568        )
1569    });
1570    if !has_non_text {
1571        return Ok(Value::String(text_from_blocks(content, "\n")));
1572    }
1573    let mut blocks = Vec::new();
1574    for block in content {
1575        match block {
1576            ContentBlock::Text { text } => blocks.push(json!({"type": "input_text", "text": text})),
1577            ContentBlock::Refusal { text } => {
1578                blocks.push(json!({"type": "refusal", "refusal": text}));
1579            }
1580            ContentBlock::Image { source } => match responses_image_part(source) {
1581                Some(part) => blocks.push(part),
1582                None => {
1583                    push_lossy(
1584                        diagnostics,
1585                        policy,
1586                        "Responses codec could not map image content",
1587                    )?;
1588                    blocks.push(json!({"type": "input_text", "text": image_source_text(source)}));
1589                }
1590            },
1591            ContentBlock::Audio { source } => {
1592                blocks.push(crate::codecs::openai_media::audio_part(source)?);
1593            }
1594            ContentBlock::Video { source } => blocks.push(match source {
1595                MediaSource::Raw(raw) => json!({"type": "input_text", "text": json_string(raw)}),
1596                MediaSource::Url { url, media_type } => json!({
1597                    "type": "input_video",
1598                    "video_url": url,
1599                    "media_type": media_type,
1600                }),
1601                MediaSource::Base64 { media_type, data } => json!({
1602                    "type": "input_video",
1603                    "video": {"media_type": media_type, "data": data},
1604                }),
1605            }),
1606            ContentBlock::File { source } => match responses_file_part(source) {
1607                Some(part) => blocks.push(part),
1608                None => {
1609                    push_lossy(
1610                        diagnostics,
1611                        policy,
1612                        "Responses codec could not map file content",
1613                    )?;
1614                    blocks.push(json!({"type": "input_text", "text": file_source_text(source)}));
1615                }
1616            },
1617            ContentBlock::Mcp(_) => {
1618                return Err(TranslationError::LossyConversion(
1619                    "MCP history encoding is not implemented".into(),
1620                ));
1621            }
1622            ContentBlock::Unknown { raw, .. } => {
1623                push_lossy(
1624                    diagnostics,
1625                    policy,
1626                    "unknown content block encoded as text for Responses",
1627                )?;
1628                blocks.push(json!({"type": "input_text", "text": json_string(raw)}));
1629            }
1630            ContentBlock::Reasoning { .. }
1631            | ContentBlock::ToolCall(_)
1632            | ContentBlock::ToolResult(_) => {}
1633        }
1634    }
1635    Ok(Value::Array(blocks))
1636}
1637
1638// Encodes tool-result content as a `function_call_output.output`: a string when it is
1639// text only, otherwise the typed `input_text` / `input_image` / `input_file` parts.
1640fn encode_responses_tool_output(
1641    content: &[ContentBlock],
1642    diagnostics: &mut Vec<TranslationDiagnostic>,
1643    policy: &TranslationPolicy,
1644) -> Result<Value> {
1645    let text_only = content.iter().all(|block| {
1646        matches!(
1647            block,
1648            ContentBlock::Text { .. } | ContentBlock::Refusal { .. }
1649        )
1650    });
1651    if text_only {
1652        return Ok(Value::String(text_from_blocks(content, " ")));
1653    }
1654    encode_responses_content(content, diagnostics, policy)
1655}
1656
1657fn responses_image_part(source: &ImageSource) -> Option<Value> {
1658    if let ImageSource::Raw(raw) = source
1659        && raw.get("type").and_then(Value::as_str) == Some("input_image")
1660        && raw.get("file_id").and_then(Value::as_str).is_some()
1661    {
1662        return Some(raw.clone());
1663    }
1664    let ImagePayload { url, detail } = image_payload(source)?;
1665    let mut part = json!({"type": "input_image"});
1666    part["image_url"] = Value::String(url);
1667    if let Some(detail) = detail {
1668        part["detail"] = Value::String(detail);
1669    }
1670    Some(part)
1671}
1672
1673fn responses_file_part(source: &FileSource) -> Option<Value> {
1674    let mut part = file_payload(source)?;
1675    part.insert("type".to_string(), Value::String("input_file".to_string()));
1676    Some(Value::Object(part))
1677}
1678
1679// Encodes normalized tool definitions into Responses tool JSON.
1680// Encodes normalized tools as Responses entries.
1681//
1682// A Responses client understands Codex containers, so a tool whose name this
1683// request qualified is regrouped under the container it came from.
1684fn encode_responses_tools(
1685    tools: &[Tool],
1686    namespaces: Option<&Map<String, Value>>,
1687    custom_tools: Option<&Map<String, Value>>,
1688) -> Result<Value> {
1689    let mut out: Vec<Value> = Vec::new();
1690    let mut containers: Vec<(String, Vec<Value>)> = Vec::new();
1691    for tool in tools {
1692        let tool = tool.function().ok_or_else(|| {
1693            TranslationError::LossyConversion(
1694                "MCP and provider tool encoding is not implemented".into(),
1695            )
1696        })?;
1697        // A freeform tool goes back out exactly as the client defined it.
1698        if let Some(custom) = custom_tools.and_then(|custom| custom.get(&tool.name)) {
1699            out.push(custom.clone());
1700            continue;
1701        }
1702        let mut item = json!({
1703            "type": "function",
1704            "name": tool.name,
1705            "description": tool.description.clone().unwrap_or_default(),
1706            "parameters": tool.parameters,
1707        });
1708        if let Some(strict) = tool.strict {
1709            item["strict"] = Value::Bool(strict);
1710        }
1711        let split = namespaces.and_then(|namespaces| {
1712            crate::codex_namespaces::split_qualified_name(namespaces, &tool.name)
1713        });
1714        match split {
1715            None => out.push(item),
1716            Some((name, namespace)) => {
1717                item["name"] = Value::String(name.to_string());
1718                match containers
1719                    .iter_mut()
1720                    .find(|(existing, _)| *existing == namespace)
1721                {
1722                    Some((_, children)) => children.push(item),
1723                    None => containers.push((namespace.to_string(), vec![item])),
1724                }
1725            }
1726        }
1727    }
1728    for (namespace, children) in containers {
1729        let description = namespaces
1730            .and_then(|namespaces| {
1731                crate::codex_namespaces::namespace_description(namespaces, &namespace)
1732            })
1733            .unwrap_or_default();
1734        out.push(json!({
1735            "type": "namespace",
1736            "name": namespace,
1737            "description": description,
1738            "tools": children,
1739        }));
1740    }
1741    Ok(Value::Array(out))
1742}
1743
1744// Encodes normalized tool choice into Responses JSON.
1745fn encode_responses_tool_choice(
1746    choice: &ToolChoice,
1747    namespaces: Option<&Map<String, Value>>,
1748) -> Option<Value> {
1749    match choice {
1750        ToolChoice::Auto => Some(Value::String("auto".to_string())),
1751        ToolChoice::Required => Some(Value::String("required".to_string())),
1752        ToolChoice::None => Some(Value::String("none".to_string())),
1753        ToolChoice::Tool { name } => {
1754            let split = namespaces.and_then(|namespaces| {
1755                crate::codex_namespaces::split_qualified_name(namespaces, name)
1756            });
1757            Some(match split {
1758                Some((tool, namespace)) => {
1759                    json!({"type": "function", "name": tool, "namespace": namespace})
1760                }
1761                None => json!({"type": "function", "name": name}),
1762            })
1763        }
1764        ToolChoice::Raw(value) => Some(value.clone()),
1765    }
1766}
1767
1768// Decodes one Responses output item into a normalized response output.
1769fn decode_responses_output_item(
1770    item: &Value,
1771    diagnostics: &mut Vec<TranslationDiagnostic>,
1772    policy: &TranslationPolicy,
1773) -> Result<Option<ResponseOutput>> {
1774    let Some(item) = item.as_object() else {
1775        push_lossy(
1776            diagnostics,
1777            policy,
1778            "Responses output item is not an object",
1779        )?;
1780        return Ok(None);
1781    };
1782    match item.get("type").and_then(Value::as_str) {
1783        Some("message") => Ok(Some(ResponseOutput {
1784            url_citations: decode_responses_url_citations(item.get("content")),
1785            role: role_from_responses(item.get("role").and_then(Value::as_str)),
1786            content: decode_responses_content(item.get("content").unwrap_or(&Value::Null)),
1787            stop_reason: Some(StopReason::EndTurn),
1788        })),
1789        Some("function_call") => Ok(Some(ResponseOutput {
1790            url_citations: Vec::new(),
1791            role: Role::Assistant,
1792            content: vec![ContentBlock::ToolCall(ToolCall {
1793                id: item
1794                    .get("call_id")
1795                    .and_then(Value::as_str)
1796                    .unwrap_or_default()
1797                    .to_string(),
1798                name: item
1799                    .get("name")
1800                    .and_then(Value::as_str)
1801                    .unwrap_or_default()
1802                    .to_string(),
1803                arguments: item.get("arguments").cloned().unwrap_or_else(|| json!({})),
1804            })],
1805            stop_reason: Some(StopReason::ToolUse),
1806        })),
1807        Some("custom_tool_call") => Ok(Some(ResponseOutput {
1808            url_citations: Vec::new(),
1809            role: Role::Assistant,
1810            content: vec![ContentBlock::ToolCall(ToolCall {
1811                id: item
1812                    .get("call_id")
1813                    .and_then(Value::as_str)
1814                    .unwrap_or_default()
1815                    .to_string(),
1816                name: item
1817                    .get("name")
1818                    .and_then(Value::as_str)
1819                    .unwrap_or_default()
1820                    .to_string(),
1821                arguments: json!({
1822                    crate::codex_custom_tools::INPUT_ARGUMENT:
1823                        item.get("input").and_then(Value::as_str).unwrap_or_default()
1824                }),
1825            })],
1826            stop_reason: Some(StopReason::ToolUse),
1827        })),
1828        Some("reasoning") => Ok(Some(ResponseOutput {
1829            url_citations: Vec::new(),
1830            role: Role::Assistant,
1831            content: decode_responses_reasoning_item(item),
1832            stop_reason: None,
1833        })),
1834        Some(kind) if super::is_native_output(kind) => Ok(Some(ResponseOutput {
1835            url_citations: Vec::new(),
1836            role: Role::Assistant,
1837            content: vec![ContentBlock::Unknown {
1838                provider: WireFormat::OpenAiResponses.into(),
1839                raw: Value::Object(item.clone()),
1840            }],
1841            stop_reason: None,
1842        })),
1843        _ => Ok(None),
1844    }
1845}
1846
1847fn decode_responses_url_citations(content: Option<&Value>) -> Vec<crate::UrlCitation> {
1848    let mut citations = Vec::new();
1849    let mut text_offset = 0;
1850    for part in content.and_then(Value::as_array).into_iter().flatten() {
1851        for annotation in part
1852            .get("annotations")
1853            .and_then(Value::as_array)
1854            .into_iter()
1855            .flatten()
1856        {
1857            if annotation["type"] == "url_citation"
1858                && let Ok(mut citation) =
1859                    serde_json::from_value::<crate::UrlCitation>(annotation.clone())
1860            {
1861                citation.start_index += text_offset;
1862                citation.end_index += text_offset;
1863                citations.push(citation);
1864            }
1865        }
1866        let text = match part.get("type").and_then(Value::as_str) {
1867            Some("input_text" | "output_text" | "text") => part.get("text"),
1868            Some("refusal") => part.get("refusal"),
1869            _ => None,
1870        };
1871        text_offset += text
1872            .and_then(Value::as_str)
1873            .unwrap_or_default()
1874            .chars()
1875            .count();
1876    }
1877    citations
1878}
1879
1880// Encodes normalized response outputs into Responses output items.
1881fn encode_responses_output(outputs: &[ResponseOutput]) -> Value {
1882    Value::Array(
1883        outputs
1884            .iter()
1885            .flat_map(|output| {
1886                let has_output_items = output.content.iter().any(|block| {
1887                    matches!(block, ContentBlock::ToolCall(_))
1888                        || matches!(block, ContentBlock::Unknown { provider, raw }
1889                            if provider.as_str() == WireFormat::OpenAiResponses.as_str()
1890                                && raw["type"].as_str().is_some_and(super::is_native_output))
1891                });
1892                let text = text_from_blocks(&output.content, "");
1893                let reasoning = reasoning_text_from_blocks(&output.content, "\n");
1894                let status = if matches!(output.stop_reason, Some(StopReason::MaxTokens)) {
1895                    "incomplete"
1896                } else {
1897                    "completed"
1898                };
1899                let mut items = Vec::new();
1900
1901                let encrypted_reasoning = output.content.iter().find_map(|block| match block {
1902                    ContentBlock::Reasoning { details, .. } => encrypted_reasoning_data(details),
1903                    _ => None,
1904                });
1905                let encrypted_reasoning_id = output.content.iter().find_map(|block| match block {
1906                    ContentBlock::Reasoning { details, .. } => encrypted_reasoning_item_id(details),
1907                    _ => None,
1908                });
1909                let has_signed_reasoning = output.content.iter().any(|block| {
1910                    matches!(
1911                        block,
1912                        ContentBlock::Reasoning {
1913                            signature: Some(_),
1914                            ..
1915                        }
1916                    )
1917                });
1918                if has_signed_reasoning {
1919                    for (index, block) in output.content.iter().enumerate() {
1920                        if let ContentBlock::Reasoning {
1921                            text,
1922                            signature,
1923                            details,
1924                        } = block
1925                        {
1926                            let encrypted = signature
1927                                .as_deref()
1928                                .map(|signature| super::encode_anthropic_thinking(text, signature))
1929                                .or_else(|| encrypted_reasoning_data(details));
1930                            let id = encrypted_reasoning_item_id(details)
1931                                .unwrap_or_else(|| format!("rs_switchyard_{index}"));
1932                            items.push(encode_responses_reasoning_output(
1933                                text,
1934                                encrypted.as_deref(),
1935                                Some(&id),
1936                            ));
1937                        }
1938                    }
1939                } else if !reasoning.is_empty() || encrypted_reasoning.is_some() {
1940                    items.push(encode_responses_reasoning_output(
1941                        &reasoning,
1942                        encrypted_reasoning.as_deref(),
1943                        encrypted_reasoning_id.as_deref(),
1944                    ));
1945                }
1946
1947                if !text.is_empty() || (!has_output_items && reasoning.is_empty()) {
1948                    items.push(json!({
1949                        "type": "message",
1950                        "id": "msg_switchyard",
1951                        "status": status,
1952                        "role": role_to_responses(output.role),
1953                        "content": [{
1954                            "type": "output_text",
1955                            "text": text,
1956                            "annotations": output.url_citations.iter().map(|citation| json!({
1957                                "type": "url_citation",
1958                                "start_index": citation.start_index,
1959                                "end_index": citation.end_index,
1960                                "url": citation.url,
1961                                "title": citation.title,
1962                            })).collect::<Vec<_>>(),
1963                        }],
1964                    }));
1965                }
1966
1967                items.extend(output.content.iter().filter_map(|block| match block {
1968                    ContentBlock::ToolCall(call) => Some(json!({
1969                        "type": "function_call",
1970                        "call_id": call.id,
1971                        "name": call.name,
1972                        "arguments": json_string_python_style(&call.arguments),
1973                    })),
1974                    ContentBlock::Unknown { provider, raw }
1975                        if provider.as_str() == WireFormat::OpenAiResponses.as_str()
1976                            && raw["type"].as_str().is_some_and(super::is_native_output) =>
1977                    {
1978                        Some(raw.clone())
1979                    }
1980                    _ => None,
1981                }));
1982
1983                items
1984            })
1985            .collect(),
1986    )
1987}
1988
1989// Encodes private reasoning as a separate Responses output item. An encrypted-only item
1990// carries no text part but keeps `encrypted_content` so the client can replay it.
1991fn encode_responses_reasoning_output(
1992    text: &str,
1993    encrypted: Option<&str>,
1994    item_id: Option<&str>,
1995) -> Value {
1996    // Standard Responses shape: text as `summary_text` parts, which is what clients record.
1997    let mut summary = Vec::new();
1998    if !text.is_empty() {
1999        summary.push(json!({"type": "summary_text", "text": text}));
2000    }
2001    // Encrypted reasoning verifies only under the id it was issued with, so reuse it.
2002    let mut item = json!({
2003        "type": "reasoning",
2004        "id": item_id.unwrap_or("rs_switchyard"),
2005        "status": "completed",
2006        "summary": summary,
2007    });
2008    if let Some(encrypted) = encrypted {
2009        item["encrypted_content"] = Value::String(encrypted.to_string());
2010    }
2011    item
2012}
2013
2014// Serializes JSON with Python-like spacing to match legacy converter behavior.
2015fn json_string_python_style(value: &Value) -> String {
2016    match value {
2017        Value::Null => "null".to_string(),
2018        Value::Bool(value) => value.to_string(),
2019        Value::Number(value) => value.to_string(),
2020        Value::String(value) => serde_json::to_string(value).unwrap_or_else(|_| value.clone()),
2021        Value::Array(items) => {
2022            let body = items
2023                .iter()
2024                .map(json_string_python_style)
2025                .collect::<Vec<_>>()
2026                .join(", ");
2027            format!("[{body}]")
2028        }
2029        Value::Object(object) => {
2030            let body = object
2031                .iter()
2032                .map(|(key, value)| {
2033                    let key = serde_json::to_string(key).unwrap_or_else(|_| format!("\"{key}\""));
2034                    format!("{key}: {}", json_string_python_style(value))
2035                })
2036                .collect::<Vec<_>>()
2037                .join(", ");
2038            format!("{{{body}}}")
2039        }
2040    }
2041}
2042
2043// Normalizes Responses usage fields.
2044fn decode_responses_usage(value: Option<&Value>) -> Usage {
2045    let Some(value) = value.and_then(Value::as_object) else {
2046        return Usage::default();
2047    };
2048    let aggregate_input_tokens = value
2049        .get("input_tokens")
2050        .or_else(|| value.get("prompt_tokens"))
2051        .and_then(Value::as_u64);
2052    let cached_input_tokens = value
2053        .get("input_tokens_details")
2054        .or_else(|| value.get("prompt_tokens_details"))
2055        .and_then(|details| details.get("cached_tokens"))
2056        .and_then(Value::as_u64);
2057    let cache_creation_input_tokens = value
2058        .get("input_tokens_details")
2059        .and_then(|details| details.get("cache_write_tokens"))
2060        .and_then(Value::as_u64)
2061        .or_else(|| {
2062            value.get("prompt_tokens_details").and_then(|details| {
2063                details
2064                    .get("cache_write_tokens")
2065                    .and_then(Value::as_u64)
2066                    .or_else(|| details.get("cache_creation_tokens").and_then(Value::as_u64))
2067            })
2068        });
2069    let input_tokens = aggregate_input_tokens.map(|tokens| {
2070        tokens
2071            .saturating_sub(cached_input_tokens.unwrap_or(0))
2072            .saturating_sub(cache_creation_input_tokens.unwrap_or(0))
2073    });
2074    let output_tokens = value
2075        .get("output_tokens")
2076        .or_else(|| value.get("completion_tokens"))
2077        .and_then(Value::as_u64);
2078    Usage {
2079        provider_cost: crate::codecs::common::provider_cost(value),
2080        input_tokens,
2081        cache: crate::codecs::common::cache_usage_details(
2082            value,
2083            cached_input_tokens,
2084            cache_creation_input_tokens,
2085        ),
2086        output_tokens,
2087        total_tokens: value
2088            .get("total_tokens")
2089            .and_then(Value::as_u64)
2090            .or_else(|| {
2091                aggregate_input_tokens
2092                    .zip(output_tokens)
2093                    .map(|(input, output)| input + output)
2094            }),
2095        reasoning_tokens: value
2096            .get("output_tokens_details")
2097            .and_then(|details| details.get("reasoning_tokens"))
2098            .or_else(|| {
2099                value
2100                    .get("completion_tokens_details")
2101                    .and_then(|details| details.get("reasoning_tokens"))
2102            })
2103            .and_then(Value::as_u64),
2104    }
2105}
2106
2107// Encodes normalized usage into Responses usage JSON.
2108pub(super) fn encode_responses_usage(usage: &Usage) -> Value {
2109    let input_tokens = usage.input_tokens.unwrap_or(0)
2110        + usage.cached_input_tokens().unwrap_or(0)
2111        + usage.cache_creation_input_tokens().unwrap_or(0);
2112    // The detail objects are required by the Responses schema, so both are always present. An
2113    // upstream that reports no cache or reasoning breakdown means zero, not "unknown"; omitting
2114    // them instead yields a payload that fails to deserialize into the OpenAI SDK's
2115    // ResponseUsage, which types both as non-optional.
2116    crate::codecs::common::encode_usage_metadata(
2117        json!({
2118            "input_tokens": input_tokens,
2119            "output_tokens": usage.output_tokens.unwrap_or(0),
2120            "total_tokens": usage
2121                .total_tokens
2122                .or_else(|| Some(input_tokens + usage.output_tokens.unwrap_or(0)))
2123                .unwrap_or(0),
2124            "input_tokens_details": {
2125                "cached_tokens": usage.cached_input_tokens().unwrap_or(0),
2126                "cache_write_tokens": usage.cache_creation_input_tokens().unwrap_or(0),
2127            },
2128            "output_tokens_details": {"reasoning_tokens": usage.reasoning_tokens.unwrap_or(0)},
2129        }),
2130        usage,
2131    )
2132}
2133
2134/// Re-emits captured request extensions that the Responses format accepts.
2135///
2136/// The allowlist is Responses-specific: Chat-only fields such as
2137/// `stream_options` and `top_logprobs` are deliberately absent, so they stay
2138/// dropped on a Chat-to-Responses hop. Fields already present in `body` are
2139/// left untouched — anything the encoder generated is authoritative over a
2140/// captured extension of the same name.
2141fn copy_responses_request_extensions(
2142    body: &mut Map<String, Value>,
2143    extensions: &Map<String, Value>,
2144) {
2145    for field in [
2146        "include",
2147        "metadata",
2148        "parallel_tool_calls",
2149        "previous_response_id",
2150        "prompt_cache_key",
2151        "prompt_cache_retention",
2152        "safety_identifier",
2153        "service_tier",
2154        "store",
2155        "user",
2156    ] {
2157        if let Some(value) = extensions.get(field) {
2158            body.entry(field.to_string())
2159                .or_insert_with(|| value.clone());
2160        }
2161    }
2162}