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