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::new(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 {
563                                role,
564                                content,
565                                phase: if role == Role::Assistant {
566                                    item.get("phase").and_then(Value::as_str).map(str::to_owned)
567                                } else {
568                                    None
569                                },
570                            },
571                        );
572                    }
573                    Some("reasoning") => {
574                        // A reasoning item after a completed tool block opens the
575                        // next assistant turn: flush the finished block so this
576                        // reasoning attaches to the turn that produced it.
577                        if !pending_tool_outputs.is_empty() {
578                            flush_responses_tool_block(
579                                &mut messages,
580                                &mut pending_tool_calls,
581                                &mut pending_tool_outputs,
582                                &mut deferred_messages,
583                                &mut pending_reasoning,
584                            );
585                        }
586                        pending_reasoning.extend(decode_responses_reasoning_item(item));
587                    }
588                    Some("function_call") => {
589                        if !pending_tool_outputs.is_empty() {
590                            flush_responses_tool_block(
591                                &mut messages,
592                                &mut pending_tool_calls,
593                                &mut pending_tool_outputs,
594                                &mut deferred_messages,
595                                &mut pending_reasoning,
596                            );
597                        }
598                        let id = item
599                            .get("call_id")
600                            .and_then(Value::as_str)
601                            .filter(|id| !id.is_empty())
602                            .map(ToOwned::to_owned)
603                            .unwrap_or_else(|| match &policy.deterministic_ids {
604                                DeterministicIdPolicy::GenerateStable { prefix } => {
605                                    stable_id(prefix, index + 1)
606                                }
607                                DeterministicIdPolicy::Preserve => String::new(),
608                            });
609                        // History has to spell a tool the way its definition
610                        // does, or the transcript teaches the model a name the
611                        // upstream was never offered.
612                        let name = item
613                            .get("name")
614                            .and_then(Value::as_str)
615                            .unwrap_or_default()
616                            .to_string();
617                        let name = match item
618                            .get("namespace")
619                            .and_then(Value::as_str)
620                            .filter(|namespace| !namespace.is_empty())
621                        {
622                            Some(namespace) => {
623                                crate::codex_namespaces::qualified_tool_name(namespace, &name)
624                            }
625                            None => name,
626                        };
627                        pending_tool_calls.push(ToolCall {
628                            id,
629                            name,
630                            arguments: item.get("arguments").cloned().unwrap_or_else(|| json!({})),
631                        });
632                    }
633                    kind @ (Some("function_call_output") | Some("custom_tool_call_output")) => {
634                        let tool_call_id = item
635                            .get("call_id")
636                            .and_then(Value::as_str)
637                            .unwrap_or_default()
638                            .to_string();
639                        // A structured output keeps its typed text, image, and file parts;
640                        // any other shape rides through as one text block.
641                        let content = match item.get("output") {
642                            Some(output @ Value::Array(_)) => decode_responses_content(output),
643                            output => vec![ContentBlock::Text {
644                                text: output.map(json_string).unwrap_or_default(),
645                            }],
646                        };
647                        // An output whose call is not in this body answers a call the provider
648                        // holds behind `previous_response_id`; it stays a tool result so routing
649                        // sees a tool continuation, not a new user turn. Without stored state
650                        // the request is malformed, and the output becomes readable user text.
651                        let answers_pending_call = pending_tool_calls
652                            .iter()
653                            .any(|call| call.id == tool_call_id);
654                        if !answers_pending_call && !stored_state {
655                            let output_text = text_from_blocks(&content, " ");
656                            let message = Message::text(
657                                Role::User,
658                                format!("Tool result {tool_call_id}: {output_text}"),
659                            );
660                            push_responses_non_tool_message(
661                                &mut messages,
662                                &mut pending_tool_calls,
663                                &mut pending_tool_outputs,
664                                &mut deferred_messages,
665                                &mut pending_reasoning,
666                                message,
667                            );
668                            continue;
669                        }
670                        // The encoder types an output by the call it answers; a stored custom
671                        // output has no call in this body, so its type is carried separately.
672                        if !answers_pending_call && kind == Some("custom_tool_call_output") {
673                            custom_call_outputs.push(tool_call_id.clone());
674                        }
675                        pending_tool_outputs.push(ToolResult {
676                            tool_call_id,
677                            content,
678                            is_error: None,
679                        });
680                    }
681                    Some("custom_tool_call") => {
682                        // A freeform call carries a raw `input` string; it rides through the IR
683                        // as the single `input` argument of a function-style call.
684                        if !pending_tool_outputs.is_empty() {
685                            flush_responses_tool_block(
686                                &mut messages,
687                                &mut pending_tool_calls,
688                                &mut pending_tool_outputs,
689                                &mut deferred_messages,
690                                &mut pending_reasoning,
691                            );
692                        }
693                        let id = item
694                            .get("call_id")
695                            .and_then(Value::as_str)
696                            .filter(|id| !id.is_empty())
697                            .map(ToOwned::to_owned)
698                            .unwrap_or_else(|| match &policy.deterministic_ids {
699                                DeterministicIdPolicy::GenerateStable { prefix } => {
700                                    stable_id(prefix, index + 1)
701                                }
702                                DeterministicIdPolicy::Preserve => String::new(),
703                            });
704                        let name = item
705                            .get("name")
706                            .and_then(Value::as_str)
707                            .unwrap_or_default()
708                            .to_string();
709                        let input = item
710                            .get("input")
711                            .and_then(Value::as_str)
712                            .unwrap_or_default()
713                            .to_string();
714                        pending_tool_calls.push(ToolCall {
715                            id,
716                            name,
717                            arguments: json!({crate::codex_custom_tools::INPUT_ARGUMENT: input}),
718                        });
719                    }
720                    None => {
721                        return Err(TranslationError::InvalidValue {
722                            path: format!("$.input[{index}].type"),
723                            message: "missing type discriminator on a non-message input item"
724                                .to_string(),
725                        });
726                    }
727                    // Tool definitions, not conversation; decoded separately by the request
728                    // decoder and re-emitted in place by the request encoder.
729                    Some("additional_tools") => {}
730                    _ => {
731                        let message = Message::new(
732                            match item_type {
733                                Some(
734                                    "shell_call"
735                                    | "apply_patch_call"
736                                    | "computer_call"
737                                    | "image_generation_call",
738                                ) => Role::Assistant,
739                                Some(
740                                    "shell_call_output"
741                                    | "apply_patch_call_output"
742                                    | "computer_call_output",
743                                ) => Role::Tool,
744                                _ => Role::User,
745                            },
746                            vec![ContentBlock::Unknown {
747                                provider: WireFormat::OpenAiResponses.into(),
748                                raw: Value::Object(item.clone()),
749                            }],
750                        );
751                        flush_unattached_responses_reasoning(
752                            &mut messages,
753                            &mut pending_tool_calls,
754                            &mut pending_tool_outputs,
755                            &mut deferred_messages,
756                            &mut pending_reasoning,
757                        );
758                        push_responses_non_tool_message(
759                            &mut messages,
760                            &mut pending_tool_calls,
761                            &mut pending_tool_outputs,
762                            &mut deferred_messages,
763                            &mut pending_reasoning,
764                            message,
765                        );
766                    }
767                }
768            }
769            if !pending_tool_calls.is_empty() || !pending_tool_outputs.is_empty() {
770                flush_responses_tool_block(
771                    &mut messages,
772                    &mut pending_tool_calls,
773                    &mut pending_tool_outputs,
774                    &mut deferred_messages,
775                    &mut pending_reasoning,
776                );
777            }
778            // Trailing reasoning with no assistant turn to join keeps the
779            // historical standalone-message shape.
780            if !pending_reasoning.is_empty() {
781                messages.push(Message::new(Role::Assistant, pending_reasoning));
782            }
783            messages.append(&mut deferred_messages);
784            Ok(messages)
785        }
786        _ => Err(TranslationError::InvalidType {
787            path: "$.input".to_string(),
788            expected: "string or array",
789        }),
790    }
791}
792
793/// Places non-tool input items without breaking Responses tool-call adjacency.
794fn push_responses_non_tool_message(
795    messages: &mut Vec<Message>,
796    pending_tool_calls: &mut Vec<ToolCall>,
797    pending_tool_outputs: &mut Vec<ToolResult>,
798    deferred_messages: &mut Vec<Message>,
799    pending_reasoning: &mut Vec<ContentBlock>,
800    message: Message,
801) {
802    if !pending_tool_calls.is_empty() && pending_tool_outputs.is_empty() {
803        deferred_messages.push(message);
804        return;
805    }
806    if !pending_tool_calls.is_empty() || !pending_tool_outputs.is_empty() {
807        flush_responses_tool_block(
808            messages,
809            pending_tool_calls,
810            pending_tool_outputs,
811            deferred_messages,
812            pending_reasoning,
813        );
814    }
815    messages.push(message);
816}
817
818/// Emits reasoning that cannot join an assistant turn as a standalone assistant
819/// message at its original position. While tool calls are pending, the
820/// reasoning belongs to the in-flight turn and stays pending for the flush.
821fn flush_unattached_responses_reasoning(
822    messages: &mut Vec<Message>,
823    pending_tool_calls: &mut Vec<ToolCall>,
824    pending_tool_outputs: &mut Vec<ToolResult>,
825    deferred_messages: &mut Vec<Message>,
826    pending_reasoning: &mut Vec<ContentBlock>,
827) {
828    if pending_reasoning.is_empty() || !pending_tool_calls.is_empty() {
829        return;
830    }
831    let message = Message::new(Role::Assistant, std::mem::take(pending_reasoning));
832    push_responses_non_tool_message(
833        messages,
834        pending_tool_calls,
835        pending_tool_outputs,
836        deferred_messages,
837        pending_reasoning,
838        message,
839    );
840    // Instructions held behind the reasoning follow it.
841    messages.append(deferred_messages);
842}
843
844/// Flushes pending function calls and matching outputs while preserving adjacency.
845/// Pending reasoning rides in the same assistant message as the tool calls so a
846/// Codex reasoning + function_call turn stays one assistant turn downstream.
847fn flush_responses_tool_block(
848    messages: &mut Vec<Message>,
849    pending_tool_calls: &mut Vec<ToolCall>,
850    pending_tool_outputs: &mut Vec<ToolResult>,
851    deferred_messages: &mut Vec<Message>,
852    pending_reasoning: &mut Vec<ContentBlock>,
853) {
854    if !pending_tool_calls.is_empty() {
855        let mut content = std::mem::take(pending_reasoning);
856        content.extend(
857            std::mem::take(pending_tool_calls)
858                .into_iter()
859                .map(ContentBlock::ToolCall),
860        );
861        messages.push(Message::new(Role::Assistant, content));
862    }
863
864    for output in std::mem::take(pending_tool_outputs) {
865        messages.push(Message::new(
866            Role::User,
867            vec![ContentBlock::ToolResult(output)],
868        ));
869    }
870
871    messages.append(deferred_messages);
872}
873
874// Decodes a Responses reasoning item into private reasoning IR content.
875fn decode_responses_reasoning_item(item: &Map<String, Value>) -> Vec<ContentBlock> {
876    if let Some((text, signature)) = item
877        .get("encrypted_content")
878        .and_then(Value::as_str)
879        .and_then(super::decode_anthropic_thinking)
880    {
881        return vec![ContentBlock::Reasoning {
882            text,
883            signature: Some(signature),
884            details: Vec::new(),
885        }];
886    }
887    let mut parts = Vec::new();
888    collect_responses_reasoning_text(item.get("content"), &mut parts);
889    collect_responses_reasoning_text(item.get("summary"), &mut parts);
890    if let Some(text) = item.get("text").and_then(Value::as_str) {
891        parts.push(text.to_string());
892    }
893    // Encrypted reasoning must be replayed under the provider-issued item id;
894    // retain the original item only when it carries that opaque payload.
895    let details = if item.get("encrypted_content").is_some() {
896        vec![Value::Object(item.clone())]
897    } else {
898        Vec::new()
899    };
900    vec![ContentBlock::Reasoning {
901        text: parts.join("\n"),
902        signature: None,
903        details,
904    }]
905}
906
907// Decodes Responses content arrays or strings into normalized content blocks.
908fn decode_responses_content(value: &Value) -> Vec<ContentBlock> {
909    match value {
910        Value::String(text) => vec![ContentBlock::Text { text: text.clone() }],
911        Value::Array(blocks) => {
912            let mut out = Vec::new();
913            for block in blocks {
914                let Some(block) = block.as_object() else {
915                    continue;
916                };
917                match block.get("type").and_then(Value::as_str) {
918                    Some("input_text") | Some("output_text") | Some("text") => {
919                        out.push(ContentBlock::Text {
920                            text: block
921                                .get("text")
922                                .and_then(Value::as_str)
923                                .unwrap_or_default()
924                                .to_string(),
925                        });
926                    }
927                    Some("refusal") => out.push(ContentBlock::Refusal {
928                        text: block
929                            .get("refusal")
930                            .and_then(Value::as_str)
931                            .unwrap_or_default()
932                            .to_string(),
933                    }),
934                    Some("reasoning_text") | Some("summary_text") => {
935                        out.push(ContentBlock::Reasoning {
936                            text: block
937                                .get("text")
938                                .and_then(Value::as_str)
939                                .unwrap_or_default()
940                                .to_string(),
941                            signature: None,
942                            details: Vec::new(),
943                        });
944                    }
945                    Some("input_image") => {
946                        if let Some(source) = decode_image_source(block) {
947                            out.push(ContentBlock::Image { source });
948                        }
949                    }
950                    Some("input_audio") => out.push(ContentBlock::Audio {
951                        source: MediaSource::Raw(Value::Object(block.clone())),
952                    }),
953                    Some("input_file") => out.push(ContentBlock::File {
954                        source: decode_file_source(block),
955                    }),
956                    _ => out.push(ContentBlock::Unknown {
957                        provider: WireFormat::OpenAiResponses.into(),
958                        raw: Value::Object(block.clone()),
959                    }),
960                }
961            }
962            if out.is_empty() {
963                out.push(ContentBlock::Text {
964                    text: String::new(),
965                });
966            }
967            out
968        }
969        Value::Null => vec![ContentBlock::Text {
970            text: String::new(),
971        }],
972        other => vec![ContentBlock::Text {
973            text: string_value(other).unwrap_or_default(),
974        }],
975    }
976}
977
978// Decodes Responses role strings into normalized roles. Used for decoding
979// provider *responses*, which stay lenient — an unexpected role from upstream
980// is coerced to `user` rather than failing the whole response.
981fn role_from_responses(role: Option<&str>) -> Role {
982    match role {
983        Some("assistant") => Role::Assistant,
984        Some("system") => Role::System,
985        Some("developer") => Role::Developer,
986        _ => Role::User,
987    }
988}
989
990// Decodes a Responses *request* message role, enforcing the provider contract.
991//
992// Unlike `role_from_responses` (used for provider responses), request decoding
993// rejects an unknown role such as "api" with [`TranslationError::InvalidValue`]
994// so the router surfaces the same `invalid_value` error the provider would,
995// instead of silently coercing it to `user`. A missing role and
996// known-but-unmapped roles keep their historical mapping to `user`.
997fn request_role_from_responses(role: Option<&str>, path: &str) -> Result<Role> {
998    match role {
999        Some("assistant") => Ok(Role::Assistant),
1000        Some("system") => Ok(Role::System),
1001        Some("developer") => Ok(Role::Developer),
1002        None => Ok(Role::User),
1003        Some(other) if is_known_role_name(other) => Ok(Role::User),
1004        Some(other) => Err(TranslationError::unsupported_role(path, other)),
1005    }
1006}
1007
1008// Decodes Responses tool shapes, including Codex-style tool entries.
1009// Decodes Responses tool shapes, including Codex-style tool entries.
1010//
1011// Codex groups tools into non-standard ``namespace`` containers that
1012// OpenAI-compatible upstreams do not accept. Each child is exposed under a
1013// `<namespace>__<tool>` name, and `namespaces` records the mapping so the
1014// response can split it back apart.
1015fn decode_responses_tools(
1016    value: Option<&Value>,
1017    namespaces: &mut Map<String, Value>,
1018    custom_tools: &mut Map<String, Value>,
1019) -> Vec<ToolDefinition> {
1020    let Some(tools) = value.and_then(Value::as_array) else {
1021        return Vec::new();
1022    };
1023    let mut out = Vec::new();
1024    for tool in tools {
1025        let Some(tool) = tool.as_object() else {
1026            continue;
1027        };
1028        if tool.get("type").and_then(Value::as_str) == Some("namespace") {
1029            // An unnamed container carries nothing to dispatch on, so its
1030            // children stay bare rather than gaining an empty prefix.
1031            let container = tool
1032                .get("name")
1033                .and_then(Value::as_str)
1034                .filter(|name| !name.is_empty());
1035            let description = tool.get("description").and_then(Value::as_str);
1036            for mut child in decode_responses_tools(tool.get("tools"), namespaces, custom_tools) {
1037                // A nested container already qualified its own children, and the
1038                // innermost name is the one that identifies the tool.
1039                let already_qualified = namespaces.contains_key(&child.name);
1040                if let Some(container) = container
1041                    && !already_qualified
1042                {
1043                    let qualified =
1044                        crate::codex_namespaces::qualified_tool_name(container, &child.name);
1045                    crate::codex_namespaces::record_tool_namespace(
1046                        namespaces,
1047                        &qualified,
1048                        container,
1049                        description,
1050                    );
1051                    child.name = qualified;
1052                }
1053                out.push(child);
1054            }
1055        } else if tool.get("type").and_then(Value::as_str) == Some("custom") {
1056            // A freeform tool takes a raw string, not JSON arguments. The IR sees it as a
1057            // function with a single `input` argument; the verbatim definition is kept so a
1058            // Responses upstream still receives the freeform tool. The definition is keyed by
1059            // the tool's own name: Codex defines its freeform tools at the top level, so a
1060            // custom tool nested in a namespace container (which would be renamed above) is
1061            // not restored verbatim and goes upstream as the equivalent function tool.
1062            if let Some(name) = tool
1063                .get("name")
1064                .and_then(Value::as_str)
1065                .filter(|name| !name.is_empty())
1066            {
1067                custom_tools.insert(name.to_string(), Value::Object(tool.clone()));
1068                out.push(ToolDefinition {
1069                    name: name.to_string(),
1070                    description: tool
1071                        .get("description")
1072                        .and_then(Value::as_str)
1073                        .map(ToOwned::to_owned),
1074                    parameters: crate::codex_custom_tools::input_schema(),
1075                    strict: None,
1076                });
1077            }
1078        } else if tool.get("type").and_then(Value::as_str) == Some("function") {
1079            if let Some(function) = tool.get("function").and_then(Value::as_object) {
1080                if let Some(name) = function.get("name").and_then(Value::as_str)
1081                    && !name.is_empty()
1082                {
1083                    out.push(ToolDefinition {
1084                        name: name.to_string(),
1085                        description: function
1086                            .get("description")
1087                            .and_then(Value::as_str)
1088                            .map(ToOwned::to_owned),
1089                        parameters: function
1090                            .get("parameters")
1091                            .cloned()
1092                            .unwrap_or_else(|| json!({})),
1093                        strict: function.get("strict").and_then(Value::as_bool),
1094                    });
1095                }
1096            } else {
1097                if !push_responses_function_tool(&mut out, tool) {
1098                    push_responses_id_tool(&mut out, tool);
1099                }
1100            }
1101        } else if tool.get("type").is_none() && tool.contains_key("name") {
1102            push_responses_function_tool(&mut out, tool);
1103        } else {
1104            push_responses_id_tool(&mut out, tool);
1105        }
1106    }
1107    out
1108}
1109
1110// Adds an OpenAI-style function tool to the normalized tool list.
1111fn push_responses_function_tool(
1112    out: &mut Vec<ToolDefinition>,
1113    tool: &serde_json::Map<String, Value>,
1114) -> bool {
1115    let Some(name) = tool.get("name").and_then(Value::as_str) else {
1116        return false;
1117    };
1118    if name.is_empty() {
1119        return false;
1120    }
1121
1122    out.push(ToolDefinition {
1123        name: name.to_string(),
1124        description: tool
1125            .get("description")
1126            .and_then(Value::as_str)
1127            .map(ToOwned::to_owned),
1128        parameters: tool.get("parameters").cloned().unwrap_or_else(|| json!({})),
1129        strict: tool.get("strict").and_then(Value::as_bool),
1130    });
1131    true
1132}
1133
1134// Adds a Codex-style ID tool to the normalized tool list.
1135fn push_responses_id_tool(
1136    out: &mut Vec<ToolDefinition>,
1137    tool: &serde_json::Map<String, Value>,
1138) -> bool {
1139    let Some(id) = tool.get("id").and_then(Value::as_str) else {
1140        return false;
1141    };
1142    if id.is_empty() {
1143        return false;
1144    }
1145
1146    out.push(ToolDefinition {
1147        name: id.to_string(),
1148        description: tool
1149            .get("description")
1150            .and_then(Value::as_str)
1151            .map(ToOwned::to_owned),
1152        parameters: tool
1153            .get("inputSchema")
1154            .and_then(Value::as_object)
1155            .and_then(|schema| schema.get("jsonSchema"))
1156            .cloned()
1157            .unwrap_or_else(|| json!({})),
1158        strict: None,
1159    });
1160    true
1161}
1162
1163// Decodes Responses tool-choice values into normalized policy.
1164fn decode_responses_tool_choice(value: &Value) -> Option<ToolChoice> {
1165    match value {
1166        Value::String(text) if text == "auto" => Some(ToolChoice::Auto),
1167        Value::String(text) if text == "required" => Some(ToolChoice::Required),
1168        Value::String(text) if text == "none" => Some(ToolChoice::None),
1169        Value::Object(object) if object.get("type").and_then(Value::as_str) == Some("function") => {
1170            // A forced tool has to name the same thing its definition does, or
1171            // the upstream rejects a choice naming a tool it was never offered.
1172            object.get("name").and_then(Value::as_str).map(|name| {
1173                match object
1174                    .get("namespace")
1175                    .and_then(Value::as_str)
1176                    .filter(|namespace| !namespace.is_empty())
1177                {
1178                    Some(namespace) => ToolChoice::Tool {
1179                        name: crate::codex_namespaces::qualified_tool_name(namespace, name),
1180                    },
1181                    None => ToolChoice::Tool {
1182                        name: name.to_string(),
1183                    },
1184                }
1185            })
1186        }
1187        Value::Object(object)
1188            if object.get("type").and_then(Value::as_str) == Some("allowed_tools") =>
1189        {
1190            Some(ToolChoice::Raw(value.clone()))
1191        }
1192        Value::Object(_) => None,
1193        _ => Some(ToolChoice::Raw(value.clone())),
1194    }
1195}
1196
1197// Maps Responses text format options into Chat-compatible response_format JSON.
1198fn decode_responses_text_format(value: Option<&Value>) -> Option<Value> {
1199    let format = value
1200        .and_then(Value::as_object)
1201        .and_then(|object| object.get("format"))
1202        .and_then(Value::as_object)?;
1203
1204    match format.get("type").and_then(Value::as_str) {
1205        Some("json_schema") => {
1206            if let Some(json_schema) = format.get("json_schema").and_then(Value::as_object) {
1207                return Some(json!({
1208                    "type": "json_schema",
1209                    "json_schema": Value::Object(json_schema.clone()),
1210                }));
1211            }
1212
1213            let mut json_schema = Map::new();
1214            for field in ["name", "description", "schema", "strict"] {
1215                if let Some(value) = format.get(field) {
1216                    json_schema.insert(field.to_string(), value.clone());
1217                }
1218            }
1219            (!json_schema.is_empty()).then_some(json!({
1220                "type": "json_schema",
1221                "json_schema": Value::Object(json_schema),
1222            }))
1223        }
1224        Some("json_object") => Some(json!({"type": "json_object"})),
1225        Some("text") => Some(json!({"type": "text"})),
1226        _ => None,
1227    }
1228}
1229
1230// Flattens Chat-compatible JSON schema fields into the Responses text format shape.
1231fn encode_responses_text_format(response_format: &Value) -> Value {
1232    let Some(format) = response_format.as_object() else {
1233        return response_format.clone();
1234    };
1235    let Some(response_type) = format
1236        .get("type")
1237        .filter(|value| value.as_str() == Some("json_schema"))
1238    else {
1239        return response_format.clone();
1240    };
1241
1242    let Some(Value::Object(json_schema)) = format.get("json_schema") else {
1243        return response_format.clone();
1244    };
1245    if !matches!(json_schema.get("name"), Some(Value::String(_)))
1246        || !matches!(json_schema.get("schema"), Some(Value::Object(_)))
1247        || json_schema
1248            .get("description")
1249            .is_some_and(|value| !value.is_string())
1250        || json_schema
1251            .get("strict")
1252            .is_some_and(|value| !value.is_boolean())
1253    {
1254        return response_format.clone();
1255    }
1256
1257    let mut output = Map::new();
1258    output.insert("type".to_string(), response_type.clone());
1259    for field in ["name", "description", "schema", "strict"] {
1260        if let Some(value) = json_schema.get(field) {
1261            output.insert(field.to_string(), value.clone());
1262        }
1263    }
1264    Value::Object(output)
1265}
1266
1267// Encodes normalized messages into the Responses `input` shape.
1268fn encode_responses_input(
1269    messages: &[Message],
1270    diagnostics: &mut Vec<TranslationDiagnostic>,
1271    policy: &TranslationPolicy,
1272    namespaces: Option<&Map<String, Value>>,
1273    custom_tools: &std::collections::HashSet<String>,
1274    mut custom_call_ids: std::collections::HashSet<String>,
1275) -> Result<Value> {
1276    // Call ids of freeform tool calls emitted by this pass, seeded with the ids of stored-state
1277    // custom outputs. A tool result is typed by the call it answers, and Responses history
1278    // always lists the call before its output, so recording ids as calls are encoded is enough
1279    // to type the outputs that follow.
1280    //
1281    // A lone user text is still sent as an item list: the Codex backend rejects a string `input`.
1282    let mut encoded = Vec::new();
1283    // Some upstream translators (Kimi K3) resolve a tool output by an explicit
1284    // `name`, so pair every output with the name of the call it answers.
1285    let mut call_names: HashMap<&str, &str> = HashMap::new();
1286    for message in messages {
1287        for block in &message.content {
1288            if let ContentBlock::ToolCall(call) = block {
1289                call_names.insert(call.id.as_str(), call.name.as_str());
1290            }
1291        }
1292    }
1293    for message in messages {
1294        // The decoder carries each native top-level input item as one
1295        // provider-qualified block, so replay that block in place.
1296        if let [ContentBlock::Unknown { provider, raw }] = message.content.as_slice()
1297            && provider.as_str() == WireFormat::OpenAiResponses.as_str()
1298            && is_responses_native_input_item(raw)
1299        {
1300            encoded.push(raw.clone());
1301            continue;
1302        }
1303        // Anthropic-signed thinking cannot be sent as Responses input.
1304        let content = message
1305            .content
1306            .iter()
1307            .filter(|block| {
1308                !matches!(
1309                    block,
1310                    ContentBlock::Reasoning {
1311                        signature: Some(_),
1312                        ..
1313                    }
1314                )
1315            })
1316            .cloned()
1317            .collect::<Vec<_>>();
1318        if content.is_empty() {
1319            continue;
1320        }
1321        if content.iter().any(|block| {
1322            matches!(
1323                block,
1324                ContentBlock::ToolCall(_) | ContentBlock::ToolResult(_)
1325            )
1326        }) {
1327            let mut visible_content = Vec::new();
1328            for block in &content {
1329                if let Some(item) = encode_responses_special_input(
1330                    block,
1331                    namespaces,
1332                    &call_names,
1333                    custom_tools,
1334                    &mut custom_call_ids,
1335                    diagnostics,
1336                    policy,
1337                )? {
1338                    if !visible_content.is_empty() {
1339                        let content =
1340                            encode_responses_content(&visible_content, diagnostics, policy)?;
1341                        encoded.push(encode_responses_message(message, content));
1342                        visible_content.clear();
1343                    }
1344                    encoded.push(item);
1345                } else if !matches!(block, ContentBlock::Reasoning { .. }) {
1346                    visible_content.push(block.clone());
1347                }
1348            }
1349            if !visible_content.is_empty() {
1350                let content = encode_responses_content(&visible_content, diagnostics, policy)?;
1351                encoded.push(encode_responses_message(message, content));
1352            }
1353            continue;
1354        }
1355        let mut visible_content = Vec::new();
1356        let mut emitted_special = false;
1357        let mut omitted_reasoning = false;
1358        for block in &content {
1359            if let Some(item) = encode_responses_special_input(
1360                block,
1361                namespaces,
1362                &call_names,
1363                custom_tools,
1364                &mut custom_call_ids,
1365                diagnostics,
1366                policy,
1367            )? {
1368                encoded.push(item);
1369                emitted_special = true;
1370            } else if !matches!(block, ContentBlock::Reasoning { .. }) {
1371                visible_content.push(block.clone());
1372            } else {
1373                omitted_reasoning = true;
1374            }
1375        }
1376        if !visible_content.is_empty()
1377            || (!emitted_special && !omitted_reasoning)
1378            || (message.role == Role::Assistant && message.phase.is_some())
1379        {
1380            let content = encode_responses_content(&visible_content, diagnostics, policy)?;
1381            encoded.push(encode_responses_message(message, content));
1382        }
1383    }
1384    pair_tool_calls_with_outputs(&mut encoded);
1385    Ok(Value::Array(encoded))
1386}
1387
1388// Responses defines `phase` only on assistant message items, so drop it elsewhere.
1389fn encode_responses_message(message: &Message, content: Value) -> Value {
1390    let mut item = json!({
1391        "type": "message",
1392        "role": role_to_responses(message.role),
1393        "content": content,
1394    });
1395    if let (Role::Assistant, Some(phase)) = (message.role, &message.phase) {
1396        item["phase"] = json!(phase);
1397    }
1398    item
1399}
1400
1401// Moves each tool output directly behind the call it answers. A turn with
1402// parallel tool calls otherwise serializes as call,call,output,output, which
1403// upstreams that pair by adjacency (Kimi K3) resolve to the wrong call.
1404fn pair_tool_calls_with_outputs(items: &mut Vec<Value>) {
1405    let call_id = |item: &Value, kind: &str| {
1406        (item.get("type").and_then(Value::as_str) == Some(kind))
1407            .then(|| {
1408                item.get("call_id")
1409                    .and_then(Value::as_str)
1410                    .map(str::to_owned)
1411            })
1412            .flatten()
1413    };
1414    let mut index = 0;
1415    while index < items.len() {
1416        // Freeform (`custom`) calls pair with `custom_tool_call_output` the same way.
1417        let (id, output_kind) = match call_id(&items[index], "function_call") {
1418            Some(id) => (id, "function_call_output"),
1419            None => match call_id(&items[index], "custom_tool_call") {
1420                Some(id) => (id, "custom_tool_call_output"),
1421                None => {
1422                    index += 1;
1423                    continue;
1424                }
1425            },
1426        };
1427        let output = items
1428            .iter()
1429            .skip(index + 1)
1430            .take_while(|item| item.get("type").and_then(Value::as_str) != Some("message"))
1431            .position(|item| call_id(item, output_kind).as_deref() == Some(&id))
1432            .map(|offset| index + 1 + offset);
1433        if let Some(output) = output
1434            && output != index + 1
1435        {
1436            let item = items.remove(output);
1437            items.insert(index + 1, item);
1438        }
1439        index += 1;
1440    }
1441}
1442
1443// Encodes IR blocks that Responses represents as top-level input items.
1444fn encode_responses_special_input(
1445    block: &ContentBlock,
1446    namespaces: Option<&Map<String, Value>>,
1447    call_names: &HashMap<&str, &str>,
1448    custom_tools: &std::collections::HashSet<String>,
1449    custom_call_ids: &mut std::collections::HashSet<String>,
1450    diagnostics: &mut Vec<TranslationDiagnostic>,
1451    policy: &TranslationPolicy,
1452) -> Result<Option<Value>> {
1453    Ok(match block {
1454        ContentBlock::Reasoning {
1455            text,
1456            signature: None,
1457            details,
1458        } => encode_responses_reasoning_input(text, details),
1459        ContentBlock::ToolCall(call) if custom_tools.contains(&call.name) => {
1460            // A freeform tool call replays as `custom_tool_call` with its raw input.
1461            custom_call_ids.insert(call.id.clone());
1462            Some(json!({
1463                "type": "custom_tool_call",
1464                "call_id": call.id,
1465                "name": call.name,
1466                "input": crate::codex_custom_tools::input_from_arguments(&call.arguments),
1467            }))
1468        }
1469        ContentBlock::ToolCall(call) => {
1470            // A Responses client dispatches on name plus namespace, so undo the
1471            // qualification this request applied for a flat upstream.
1472            let (name, namespace) = namespaces
1473                .and_then(|namespaces| {
1474                    crate::codex_namespaces::split_qualified_name(namespaces, &call.name)
1475                })
1476                .map_or((call.name.as_str(), None), |(name, namespace)| {
1477                    (name, Some(namespace))
1478                });
1479            let mut item = json!({
1480                "type": "function_call",
1481                "call_id": call.id,
1482                "name": name,
1483                "arguments": json_string(&call.arguments),
1484            });
1485            if let Some(namespace) = namespace {
1486                item["namespace"] = Value::String(namespace.to_string());
1487            }
1488            Some(item)
1489        }
1490        ContentBlock::ToolResult(result) => {
1491            // A result answering a freeform call replays as `custom_tool_call_output`; anything
1492            // else is a `function_call_output`.
1493            let mut item = json!({
1494                "type": if custom_call_ids.contains(&result.tool_call_id) {
1495                    "custom_tool_call_output"
1496                } else {
1497                    "function_call_output"
1498                },
1499                "call_id": result.tool_call_id,
1500                "output": encode_responses_tool_output(&result.content, diagnostics, policy)?,
1501            });
1502            // Carry the paired call's name, un-qualified to match the emitted
1503            // function_call, for upstreams that resolve outputs by name.
1504            if let Some(name) = call_names.get(result.tool_call_id.as_str()) {
1505                let name = namespaces
1506                    .and_then(|namespaces| {
1507                        crate::codex_namespaces::split_qualified_name(namespaces, name)
1508                    })
1509                    .map_or(*name, |(name, _)| name);
1510                item["name"] = Value::String(name.to_string());
1511            }
1512            Some(item)
1513        }
1514        _ => None,
1515    })
1516}
1517
1518// Encodes reasoning in the shape accepted for Responses input history. Response
1519// output items may carry `content`, but replayed input items must avoid it.
1520fn encode_responses_reasoning_input(text: &str, details: &[Value]) -> Option<Value> {
1521    for detail in details {
1522        let Some(detail) = detail.as_object() else {
1523            continue;
1524        };
1525        if detail.get("type").and_then(Value::as_str) != Some("reasoning") {
1526            continue;
1527        }
1528        let mut item = Map::new();
1529        item.insert("type".to_string(), Value::String("reasoning".to_string()));
1530        for field in ["id", "summary", "encrypted_content"] {
1531            if let Some(value) = detail.get(field) {
1532                item.insert(field.to_string(), value.clone());
1533            }
1534        }
1535        item.entry("summary".to_string())
1536            .or_insert_with(|| Value::Array(Vec::new()));
1537        return Some(Value::Object(item));
1538    }
1539
1540    if text.is_empty() {
1541        return None;
1542    }
1543    Some(json!({
1544        "type": "reasoning",
1545        "summary": [{"type": "summary_text", "text": text}],
1546    }))
1547}
1548
1549// Maps normalized roles back to Responses role strings.
1550fn role_to_responses(role: Role) -> &'static str {
1551    match role {
1552        Role::Assistant => "assistant",
1553        Role::System => "system",
1554        Role::Developer => "developer",
1555        Role::User | Role::Tool => "user",
1556    }
1557}
1558
1559// Encodes normalized content into Responses message content.
1560fn encode_responses_content(
1561    content: &[ContentBlock],
1562    diagnostics: &mut Vec<TranslationDiagnostic>,
1563    policy: &TranslationPolicy,
1564) -> Result<Value> {
1565    let has_non_text = content.iter().any(|block| {
1566        !matches!(
1567            block,
1568            ContentBlock::Text { .. }
1569                | ContentBlock::Refusal { .. }
1570                | ContentBlock::Reasoning { .. }
1571        )
1572    });
1573    if !has_non_text {
1574        return Ok(Value::String(text_from_blocks(content, "\n")));
1575    }
1576    let mut blocks = Vec::new();
1577    for block in content {
1578        match block {
1579            ContentBlock::Text { text } => blocks.push(json!({"type": "input_text", "text": text})),
1580            ContentBlock::Refusal { text } => {
1581                blocks.push(json!({"type": "refusal", "refusal": text}));
1582            }
1583            ContentBlock::Image { source } => match responses_image_part(source) {
1584                Some(part) => blocks.push(part),
1585                None => {
1586                    push_lossy(
1587                        diagnostics,
1588                        policy,
1589                        "Responses codec could not map image content",
1590                    )?;
1591                    blocks.push(json!({"type": "input_text", "text": image_source_text(source)}));
1592                }
1593            },
1594            ContentBlock::Audio { source } => {
1595                blocks.push(crate::codecs::openai_media::audio_part(source)?);
1596            }
1597            ContentBlock::Video { source } => blocks.push(match source {
1598                MediaSource::Raw(raw) => json!({"type": "input_text", "text": json_string(raw)}),
1599                MediaSource::Url { url, media_type } => json!({
1600                    "type": "input_video",
1601                    "video_url": url,
1602                    "media_type": media_type,
1603                }),
1604                MediaSource::Base64 { media_type, data } => json!({
1605                    "type": "input_video",
1606                    "video": {"media_type": media_type, "data": data},
1607                }),
1608            }),
1609            ContentBlock::File { source } => match responses_file_part(source) {
1610                Some(part) => blocks.push(part),
1611                None => {
1612                    push_lossy(
1613                        diagnostics,
1614                        policy,
1615                        "Responses codec could not map file content",
1616                    )?;
1617                    blocks.push(json!({"type": "input_text", "text": file_source_text(source)}));
1618                }
1619            },
1620            ContentBlock::Mcp(_) => {
1621                return Err(TranslationError::LossyConversion(
1622                    "MCP history encoding is not implemented".into(),
1623                ));
1624            }
1625            ContentBlock::Unknown { raw, .. } => {
1626                push_lossy(
1627                    diagnostics,
1628                    policy,
1629                    "unknown content block encoded as text for Responses",
1630                )?;
1631                blocks.push(json!({"type": "input_text", "text": json_string(raw)}));
1632            }
1633            ContentBlock::Reasoning { .. }
1634            | ContentBlock::ToolCall(_)
1635            | ContentBlock::ToolResult(_) => {}
1636        }
1637    }
1638    Ok(Value::Array(blocks))
1639}
1640
1641// Encodes tool-result content as a `function_call_output.output`: a string when it is
1642// text only, otherwise the typed `input_text` / `input_image` / `input_file` parts.
1643fn encode_responses_tool_output(
1644    content: &[ContentBlock],
1645    diagnostics: &mut Vec<TranslationDiagnostic>,
1646    policy: &TranslationPolicy,
1647) -> Result<Value> {
1648    let text_only = content.iter().all(|block| {
1649        matches!(
1650            block,
1651            ContentBlock::Text { .. } | ContentBlock::Refusal { .. }
1652        )
1653    });
1654    if text_only {
1655        return Ok(Value::String(text_from_blocks(content, " ")));
1656    }
1657    encode_responses_content(content, diagnostics, policy)
1658}
1659
1660fn responses_image_part(source: &ImageSource) -> Option<Value> {
1661    if let ImageSource::Raw(raw) = source
1662        && raw.get("type").and_then(Value::as_str) == Some("input_image")
1663        && raw.get("file_id").and_then(Value::as_str).is_some()
1664    {
1665        return Some(raw.clone());
1666    }
1667    let ImagePayload { url, detail } = image_payload(source)?;
1668    let mut part = json!({"type": "input_image"});
1669    part["image_url"] = Value::String(url);
1670    if let Some(detail) = detail {
1671        part["detail"] = Value::String(detail);
1672    }
1673    Some(part)
1674}
1675
1676fn responses_file_part(source: &FileSource) -> Option<Value> {
1677    let mut part = file_payload(source)?;
1678    part.insert("type".to_string(), Value::String("input_file".to_string()));
1679    Some(Value::Object(part))
1680}
1681
1682// Encodes normalized tool definitions into Responses tool JSON.
1683// Encodes normalized tools as Responses entries.
1684//
1685// A Responses client understands Codex containers, so a tool whose name this
1686// request qualified is regrouped under the container it came from.
1687fn encode_responses_tools(
1688    tools: &[Tool],
1689    namespaces: Option<&Map<String, Value>>,
1690    custom_tools: Option<&Map<String, Value>>,
1691) -> Result<Value> {
1692    let mut out: Vec<Value> = Vec::new();
1693    let mut containers: Vec<(String, Vec<Value>)> = Vec::new();
1694    for tool in tools {
1695        let tool = tool.function().ok_or_else(|| {
1696            TranslationError::LossyConversion(
1697                "MCP and provider tool encoding is not implemented".into(),
1698            )
1699        })?;
1700        // A freeform tool goes back out exactly as the client defined it.
1701        if let Some(custom) = custom_tools.and_then(|custom| custom.get(&tool.name)) {
1702            out.push(custom.clone());
1703            continue;
1704        }
1705        let mut item = json!({
1706            "type": "function",
1707            "name": tool.name,
1708            "description": tool.description.clone().unwrap_or_default(),
1709            "parameters": tool.parameters,
1710        });
1711        if let Some(strict) = tool.strict {
1712            item["strict"] = Value::Bool(strict);
1713        }
1714        let split = namespaces.and_then(|namespaces| {
1715            crate::codex_namespaces::split_qualified_name(namespaces, &tool.name)
1716        });
1717        match split {
1718            None => out.push(item),
1719            Some((name, namespace)) => {
1720                item["name"] = Value::String(name.to_string());
1721                match containers
1722                    .iter_mut()
1723                    .find(|(existing, _)| *existing == namespace)
1724                {
1725                    Some((_, children)) => children.push(item),
1726                    None => containers.push((namespace.to_string(), vec![item])),
1727                }
1728            }
1729        }
1730    }
1731    for (namespace, children) in containers {
1732        let description = namespaces
1733            .and_then(|namespaces| {
1734                crate::codex_namespaces::namespace_description(namespaces, &namespace)
1735            })
1736            .unwrap_or_default();
1737        out.push(json!({
1738            "type": "namespace",
1739            "name": namespace,
1740            "description": description,
1741            "tools": children,
1742        }));
1743    }
1744    Ok(Value::Array(out))
1745}
1746
1747// Encodes normalized tool choice into Responses JSON.
1748fn encode_responses_tool_choice(
1749    choice: &ToolChoice,
1750    namespaces: Option<&Map<String, Value>>,
1751) -> Option<Value> {
1752    match choice {
1753        ToolChoice::Auto => Some(Value::String("auto".to_string())),
1754        ToolChoice::Required => Some(Value::String("required".to_string())),
1755        ToolChoice::None => Some(Value::String("none".to_string())),
1756        ToolChoice::Tool { name } => {
1757            let split = namespaces.and_then(|namespaces| {
1758                crate::codex_namespaces::split_qualified_name(namespaces, name)
1759            });
1760            Some(match split {
1761                Some((tool, namespace)) => {
1762                    json!({"type": "function", "name": tool, "namespace": namespace})
1763                }
1764                None => json!({"type": "function", "name": name}),
1765            })
1766        }
1767        ToolChoice::Raw(value) => Some(value.clone()),
1768    }
1769}
1770
1771// Decodes one Responses output item into a normalized response output.
1772fn decode_responses_output_item(
1773    item: &Value,
1774    diagnostics: &mut Vec<TranslationDiagnostic>,
1775    policy: &TranslationPolicy,
1776) -> Result<Option<ResponseOutput>> {
1777    let Some(item) = item.as_object() else {
1778        push_lossy(
1779            diagnostics,
1780            policy,
1781            "Responses output item is not an object",
1782        )?;
1783        return Ok(None);
1784    };
1785    match item.get("type").and_then(Value::as_str) {
1786        Some("message") => Ok(Some(ResponseOutput {
1787            url_citations: decode_responses_url_citations(item.get("content")),
1788            role: role_from_responses(item.get("role").and_then(Value::as_str)),
1789            content: decode_responses_content(item.get("content").unwrap_or(&Value::Null)),
1790            stop_reason: Some(StopReason::EndTurn),
1791        })),
1792        Some("function_call") => Ok(Some(ResponseOutput {
1793            url_citations: Vec::new(),
1794            role: Role::Assistant,
1795            content: vec![ContentBlock::ToolCall(ToolCall {
1796                id: item
1797                    .get("call_id")
1798                    .and_then(Value::as_str)
1799                    .unwrap_or_default()
1800                    .to_string(),
1801                name: item
1802                    .get("name")
1803                    .and_then(Value::as_str)
1804                    .unwrap_or_default()
1805                    .to_string(),
1806                arguments: item.get("arguments").cloned().unwrap_or_else(|| json!({})),
1807            })],
1808            stop_reason: Some(StopReason::ToolUse),
1809        })),
1810        Some("custom_tool_call") => Ok(Some(ResponseOutput {
1811            url_citations: Vec::new(),
1812            role: Role::Assistant,
1813            content: vec![ContentBlock::ToolCall(ToolCall {
1814                id: item
1815                    .get("call_id")
1816                    .and_then(Value::as_str)
1817                    .unwrap_or_default()
1818                    .to_string(),
1819                name: item
1820                    .get("name")
1821                    .and_then(Value::as_str)
1822                    .unwrap_or_default()
1823                    .to_string(),
1824                arguments: json!({
1825                    crate::codex_custom_tools::INPUT_ARGUMENT:
1826                        item.get("input").and_then(Value::as_str).unwrap_or_default()
1827                }),
1828            })],
1829            stop_reason: Some(StopReason::ToolUse),
1830        })),
1831        Some("reasoning") => Ok(Some(ResponseOutput {
1832            url_citations: Vec::new(),
1833            role: Role::Assistant,
1834            content: decode_responses_reasoning_item(item),
1835            stop_reason: None,
1836        })),
1837        Some(kind) if super::is_native_output(kind) => Ok(Some(ResponseOutput {
1838            url_citations: Vec::new(),
1839            role: Role::Assistant,
1840            content: vec![ContentBlock::Unknown {
1841                provider: WireFormat::OpenAiResponses.into(),
1842                raw: Value::Object(item.clone()),
1843            }],
1844            stop_reason: None,
1845        })),
1846        _ => Ok(None),
1847    }
1848}
1849
1850fn decode_responses_url_citations(content: Option<&Value>) -> Vec<crate::UrlCitation> {
1851    let mut citations = Vec::new();
1852    let mut text_offset = 0;
1853    for part in content.and_then(Value::as_array).into_iter().flatten() {
1854        for annotation in part
1855            .get("annotations")
1856            .and_then(Value::as_array)
1857            .into_iter()
1858            .flatten()
1859        {
1860            if annotation["type"] == "url_citation"
1861                && let Ok(mut citation) =
1862                    serde_json::from_value::<crate::UrlCitation>(annotation.clone())
1863            {
1864                citation.start_index += text_offset;
1865                citation.end_index += text_offset;
1866                citations.push(citation);
1867            }
1868        }
1869        let text = match part.get("type").and_then(Value::as_str) {
1870            Some("input_text" | "output_text" | "text") => part.get("text"),
1871            Some("refusal") => part.get("refusal"),
1872            _ => None,
1873        };
1874        text_offset += text
1875            .and_then(Value::as_str)
1876            .unwrap_or_default()
1877            .chars()
1878            .count();
1879    }
1880    citations
1881}
1882
1883// Encodes normalized response outputs into Responses output items.
1884fn encode_responses_output(outputs: &[ResponseOutput]) -> Value {
1885    Value::Array(
1886        outputs
1887            .iter()
1888            .flat_map(|output| {
1889                let has_output_items = output.content.iter().any(|block| {
1890                    matches!(block, ContentBlock::ToolCall(_))
1891                        || matches!(block, ContentBlock::Unknown { provider, raw }
1892                            if provider.as_str() == WireFormat::OpenAiResponses.as_str()
1893                                && raw["type"].as_str().is_some_and(super::is_native_output))
1894                });
1895                let text = text_from_blocks(&output.content, "");
1896                let reasoning = reasoning_text_from_blocks(&output.content, "\n");
1897                let status = if matches!(output.stop_reason, Some(StopReason::MaxTokens)) {
1898                    "incomplete"
1899                } else {
1900                    "completed"
1901                };
1902                let mut items = Vec::new();
1903
1904                let encrypted_reasoning = output.content.iter().find_map(|block| match block {
1905                    ContentBlock::Reasoning { details, .. } => encrypted_reasoning_data(details),
1906                    _ => None,
1907                });
1908                let encrypted_reasoning_id = output.content.iter().find_map(|block| match block {
1909                    ContentBlock::Reasoning { details, .. } => encrypted_reasoning_item_id(details),
1910                    _ => None,
1911                });
1912                let has_signed_reasoning = output.content.iter().any(|block| {
1913                    matches!(
1914                        block,
1915                        ContentBlock::Reasoning {
1916                            signature: Some(_),
1917                            ..
1918                        }
1919                    )
1920                });
1921                if has_signed_reasoning {
1922                    for (index, block) in output.content.iter().enumerate() {
1923                        if let ContentBlock::Reasoning {
1924                            text,
1925                            signature,
1926                            details,
1927                        } = block
1928                        {
1929                            let encrypted = signature
1930                                .as_deref()
1931                                .map(|signature| super::encode_anthropic_thinking(text, signature))
1932                                .or_else(|| encrypted_reasoning_data(details));
1933                            let id = encrypted_reasoning_item_id(details)
1934                                .unwrap_or_else(|| format!("rs_switchyard_{index}"));
1935                            items.push(encode_responses_reasoning_output(
1936                                text,
1937                                encrypted.as_deref(),
1938                                Some(&id),
1939                            ));
1940                        }
1941                    }
1942                } else if !reasoning.is_empty() || encrypted_reasoning.is_some() {
1943                    items.push(encode_responses_reasoning_output(
1944                        &reasoning,
1945                        encrypted_reasoning.as_deref(),
1946                        encrypted_reasoning_id.as_deref(),
1947                    ));
1948                }
1949
1950                if !text.is_empty() || (!has_output_items && reasoning.is_empty()) {
1951                    items.push(json!({
1952                        "type": "message",
1953                        "id": "msg_switchyard",
1954                        "status": status,
1955                        "role": role_to_responses(output.role),
1956                        "content": [{
1957                            "type": "output_text",
1958                            "text": text,
1959                            "annotations": output.url_citations.iter().map(|citation| json!({
1960                                "type": "url_citation",
1961                                "start_index": citation.start_index,
1962                                "end_index": citation.end_index,
1963                                "url": citation.url,
1964                                "title": citation.title,
1965                            })).collect::<Vec<_>>(),
1966                        }],
1967                    }));
1968                }
1969
1970                items.extend(output.content.iter().filter_map(|block| match block {
1971                    ContentBlock::ToolCall(call) => Some(json!({
1972                        "type": "function_call",
1973                        "call_id": call.id,
1974                        "name": call.name,
1975                        "arguments": json_string_python_style(&call.arguments),
1976                    })),
1977                    ContentBlock::Unknown { provider, raw }
1978                        if provider.as_str() == WireFormat::OpenAiResponses.as_str()
1979                            && raw["type"].as_str().is_some_and(super::is_native_output) =>
1980                    {
1981                        Some(raw.clone())
1982                    }
1983                    _ => None,
1984                }));
1985
1986                items
1987            })
1988            .collect(),
1989    )
1990}
1991
1992// Encodes private reasoning as a separate Responses output item. An encrypted-only item
1993// carries no text part but keeps `encrypted_content` so the client can replay it.
1994fn encode_responses_reasoning_output(
1995    text: &str,
1996    encrypted: Option<&str>,
1997    item_id: Option<&str>,
1998) -> Value {
1999    // Standard Responses shape: text as `summary_text` parts, which is what clients record.
2000    let mut summary = Vec::new();
2001    if !text.is_empty() {
2002        summary.push(json!({"type": "summary_text", "text": text}));
2003    }
2004    // Encrypted reasoning verifies only under the id it was issued with, so reuse it.
2005    let mut item = json!({
2006        "type": "reasoning",
2007        "id": item_id.unwrap_or("rs_switchyard"),
2008        "status": "completed",
2009        "summary": summary,
2010    });
2011    if let Some(encrypted) = encrypted {
2012        item["encrypted_content"] = Value::String(encrypted.to_string());
2013    }
2014    item
2015}
2016
2017// Serializes JSON with Python-like spacing to match legacy converter behavior.
2018fn json_string_python_style(value: &Value) -> String {
2019    match value {
2020        Value::Null => "null".to_string(),
2021        Value::Bool(value) => value.to_string(),
2022        Value::Number(value) => value.to_string(),
2023        Value::String(value) => serde_json::to_string(value).unwrap_or_else(|_| value.clone()),
2024        Value::Array(items) => {
2025            let body = items
2026                .iter()
2027                .map(json_string_python_style)
2028                .collect::<Vec<_>>()
2029                .join(", ");
2030            format!("[{body}]")
2031        }
2032        Value::Object(object) => {
2033            let body = object
2034                .iter()
2035                .map(|(key, value)| {
2036                    let key = serde_json::to_string(key).unwrap_or_else(|_| format!("\"{key}\""));
2037                    format!("{key}: {}", json_string_python_style(value))
2038                })
2039                .collect::<Vec<_>>()
2040                .join(", ");
2041            format!("{{{body}}}")
2042        }
2043    }
2044}
2045
2046// Normalizes Responses usage fields.
2047fn decode_responses_usage(value: Option<&Value>) -> Usage {
2048    let Some(value) = value.and_then(Value::as_object) else {
2049        return Usage::default();
2050    };
2051    let aggregate_input_tokens = value
2052        .get("input_tokens")
2053        .or_else(|| value.get("prompt_tokens"))
2054        .and_then(Value::as_u64);
2055    let cached_input_tokens = value
2056        .get("input_tokens_details")
2057        .or_else(|| value.get("prompt_tokens_details"))
2058        .and_then(|details| details.get("cached_tokens"))
2059        .and_then(Value::as_u64);
2060    let cache_creation_input_tokens = value
2061        .get("input_tokens_details")
2062        .and_then(|details| details.get("cache_write_tokens"))
2063        .and_then(Value::as_u64)
2064        .or_else(|| {
2065            value.get("prompt_tokens_details").and_then(|details| {
2066                details
2067                    .get("cache_write_tokens")
2068                    .and_then(Value::as_u64)
2069                    .or_else(|| details.get("cache_creation_tokens").and_then(Value::as_u64))
2070            })
2071        });
2072    let input_tokens = aggregate_input_tokens.map(|tokens| {
2073        tokens
2074            .saturating_sub(cached_input_tokens.unwrap_or(0))
2075            .saturating_sub(cache_creation_input_tokens.unwrap_or(0))
2076    });
2077    let output_tokens = value
2078        .get("output_tokens")
2079        .or_else(|| value.get("completion_tokens"))
2080        .and_then(Value::as_u64);
2081    Usage {
2082        provider_cost: crate::codecs::common::provider_cost(value),
2083        input_tokens,
2084        cache: crate::codecs::common::cache_usage_details(
2085            value,
2086            cached_input_tokens,
2087            cache_creation_input_tokens,
2088        ),
2089        output_tokens,
2090        total_tokens: value
2091            .get("total_tokens")
2092            .and_then(Value::as_u64)
2093            .or_else(|| {
2094                aggregate_input_tokens
2095                    .zip(output_tokens)
2096                    .map(|(input, output)| input + output)
2097            }),
2098        reasoning_tokens: value
2099            .get("output_tokens_details")
2100            .and_then(|details| details.get("reasoning_tokens"))
2101            .or_else(|| {
2102                value
2103                    .get("completion_tokens_details")
2104                    .and_then(|details| details.get("reasoning_tokens"))
2105            })
2106            .and_then(Value::as_u64),
2107    }
2108}
2109
2110// Encodes normalized usage into Responses usage JSON.
2111pub(super) fn encode_responses_usage(usage: &Usage) -> Value {
2112    let input_tokens = usage.input_tokens.unwrap_or(0)
2113        + usage.cached_input_tokens().unwrap_or(0)
2114        + usage.cache_creation_input_tokens().unwrap_or(0);
2115    // The detail objects are required by the Responses schema, so both are always present. An
2116    // upstream that reports no cache or reasoning breakdown means zero, not "unknown"; omitting
2117    // them instead yields a payload that fails to deserialize into the OpenAI SDK's
2118    // ResponseUsage, which types both as non-optional.
2119    crate::codecs::common::encode_usage_metadata(
2120        json!({
2121            "input_tokens": input_tokens,
2122            "output_tokens": usage.output_tokens.unwrap_or(0),
2123            "total_tokens": usage
2124                .total_tokens
2125                .or_else(|| Some(input_tokens + usage.output_tokens.unwrap_or(0)))
2126                .unwrap_or(0),
2127            "input_tokens_details": {
2128                "cached_tokens": usage.cached_input_tokens().unwrap_or(0),
2129                "cache_write_tokens": usage.cache_creation_input_tokens().unwrap_or(0),
2130            },
2131            "output_tokens_details": {"reasoning_tokens": usage.reasoning_tokens.unwrap_or(0)},
2132        }),
2133        usage,
2134    )
2135}
2136
2137/// Re-emits captured request extensions that the Responses format accepts.
2138///
2139/// The allowlist is Responses-specific: Chat-only fields such as
2140/// `stream_options` and `top_logprobs` are deliberately absent, so they stay
2141/// dropped on a Chat-to-Responses hop. Fields already present in `body` are
2142/// left untouched — anything the encoder generated is authoritative over a
2143/// captured extension of the same name.
2144fn copy_responses_request_extensions(
2145    body: &mut Map<String, Value>,
2146    extensions: &Map<String, Value>,
2147) {
2148    for field in [
2149        "include",
2150        "metadata",
2151        "parallel_tool_calls",
2152        "previous_response_id",
2153        "prompt_cache_key",
2154        "prompt_cache_retention",
2155        "safety_identifier",
2156        "service_tier",
2157        "store",
2158        "user",
2159    ] {
2160        if let Some(value) = extensions.get(field) {
2161            body.entry(field.to_string())
2162                .or_insert_with(|| value.clone());
2163        }
2164    }
2165}