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