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. A request that
226            // reduced to a single user text encodes `input` as a plain string, which has no
227            // room for an item, so it is widened to the equivalent one-message array first.
228            let item = json!({
229                "type": "additional_tools",
230                "role": "developer",
231                "tools": additional,
232            });
233            match body.get_mut("input") {
234                Some(Value::Array(input)) => input.insert(0, item),
235                Some(input @ Value::String(_)) => {
236                    let text = input.take();
237                    *input = Value::Array(vec![item, json!({"role": "user", "content": text})]);
238                }
239                _ => body
240                    .insert("input".to_string(), Value::Array(vec![item]))
241                    .map_or((), |_| ()),
242            }
243        } else if !request.tools.is_empty() {
244            body.insert(
245                "tools".to_string(),
246                encode_responses_tools(
247                    &request.tools,
248                    crate::codex_namespaces::tool_namespaces(&request.extensions),
249                    crate::codex_custom_tools::custom_tools(&request.extensions),
250                )?,
251            );
252        }
253        if let Some(choice) = &request.tool_choice
254            && let Some(choice) = encode_responses_tool_choice(
255                choice,
256                crate::codex_namespaces::tool_namespaces(&request.extensions),
257            )
258        {
259            body.insert("tool_choice".to_string(), choice);
260        }
261        if let Some(max_output_tokens) = request.output.max_output_tokens {
262            body.insert("max_output_tokens".to_string(), json!(max_output_tokens));
263        }
264        if let Some(response_format) =
265            encode_openai_format(&request.output, &mut diagnostics, policy)?
266        {
267            body.insert(
268                "text".to_string(),
269                json!({"format": encode_responses_text_format(&response_format)}),
270            );
271        }
272        // An Anthropic request's raw reasoning is its `thinking` object, which Responses
273        // does not accept. Keep only whether thinking is disabled.
274        let mut reasoning = if is_anthropic_request(request) {
275            let mut reasoning = Map::new();
276            if request
277                .reasoning
278                .raw
279                .as_ref()
280                .and_then(|thinking| thinking.get("type"))
281                .and_then(Value::as_str)
282                == Some("disabled")
283            {
284                reasoning.insert("effort".to_string(), json!("none"));
285            }
286            reasoning
287        } else {
288            request
289                .reasoning
290                .raw
291                .as_ref()
292                .and_then(Value::as_object)
293                .cloned()
294                .unwrap_or_default()
295        };
296        if let Some(effort) = &request.reasoning.effort {
297            reasoning.insert("effort".to_string(), json!(effort));
298        }
299        if !reasoning.is_empty() {
300            body.insert("reasoning".to_string(), Value::Object(reasoning));
301        }
302        if let Some(value) = request.sampling.temperature {
303            body.insert("temperature".to_string(), json!(value));
304        }
305        if let Some(value) = request.sampling.top_p {
306            body.insert("top_p".to_string(), json!(value));
307        }
308        if request.stream {
309            body.insert("stream".to_string(), Value::Bool(true));
310        }
311        copy_responses_request_extensions(&mut body, &request.extensions.fields);
312
313        let body = embed_preservation(Value::Object(body), &request.preservation, policy);
314        Ok(EncodedRequest { body, diagnostics })
315    }
316
317    fn decode_response(&self, body: &Value, policy: &TranslationPolicy) -> Result<DecodedResponse> {
318        let body = crate::util::object(body, "$")?;
319        // Providers can return HTTP 200 when generation fails. Check `status`
320        // before treating an empty `output` as a completed turn.
321        if body.get("status").and_then(Value::as_str) == Some("failed") {
322            return Err(TranslationError::UpstreamFailure {
323                error: body
324                    .get("error")
325                    .filter(|error| error.is_object())
326                    .cloned()
327                    .unwrap_or_else(|| {
328                        json!({ "message": "provider reported status \"failed\" without error details" })
329                    }),
330            });
331        }
332        let mut diagnostics = Vec::new();
333        let mut content = Vec::new();
334        let mut role = Role::Assistant;
335        let mut stop_reason = None;
336        let mut has_output = false;
337        let mut url_citations = Vec::new();
338        let mut text_offset = 0;
339        if let Some(items) = body.get("output").and_then(Value::as_array) {
340            for item in items {
341                if let Some(output) = decode_responses_output_item(item, &mut diagnostics, policy)?
342                {
343                    has_output = true;
344                    role = output.role;
345                    for mut citation in output.url_citations {
346                        citation.start_index += text_offset;
347                        citation.end_index += text_offset;
348                        url_citations.push(citation);
349                    }
350                    text_offset += output
351                        .content
352                        .iter()
353                        .map(|block| match block {
354                            ContentBlock::Text { text } | ContentBlock::Refusal { text } => {
355                                text.chars().count()
356                            }
357                            _ => 0,
358                        })
359                        .sum::<usize>();
360                    content.extend(output.content);
361                    if output.stop_reason.is_some() {
362                        stop_reason = output.stop_reason;
363                    }
364                }
365            }
366        }
367        // The truncation signal is on the response, not the output items.
368        if body.get("status").and_then(Value::as_str) == Some("incomplete")
369            && body
370                .get("incomplete_details")
371                .and_then(|details| details.get("reason"))
372                .and_then(Value::as_str)
373                == Some("max_output_tokens")
374        {
375            stop_reason = Some(StopReason::MaxTokens);
376        }
377        let outputs = vec![if has_output {
378            ResponseOutput {
379                url_citations,
380                role,
381                content,
382                stop_reason,
383            }
384        } else {
385            ResponseOutput {
386                url_citations: Vec::new(),
387                role: Role::Assistant,
388                content: vec![ContentBlock::Text {
389                    text: String::new(),
390                }],
391                stop_reason: Some(StopReason::EndTurn),
392            }
393        }];
394        let response = AggLlmResponse {
395            id: body
396                .get("id")
397                .and_then(Value::as_str)
398                .map(ToOwned::to_owned),
399            model: body
400                .get("model")
401                .and_then(Value::as_str)
402                .map(ToOwned::to_owned),
403            outputs,
404            usage: decode_responses_usage(body.get("usage")),
405            extensions: ProviderExtensions {
406                fields: provider_extensions(
407                    body,
408                    &["id", "model", "object", "output", "usage", "status"],
409                ),
410            },
411            preservation: capture_response_preservation(
412                WireFormat::OpenAiResponses,
413                &Value::Object(body.clone()),
414                policy,
415            ),
416        };
417        Ok(DecodedResponse {
418            response,
419            diagnostics,
420        })
421    }
422
423    fn encode_response(
424        &self,
425        response: &AggLlmResponse,
426        _policy: &TranslationPolicy,
427    ) -> Result<EncodedResponse> {
428        if let Some(body) =
429            exact_preserved_response(&response.preservation, WireFormat::OpenAiResponses, _policy)
430        {
431            return Ok(EncodedResponse {
432                body,
433                diagnostics: Vec::new(),
434            });
435        }
436        let is_truncated = matches!(
437            response
438                .first_output()
439                .and_then(|output| output.stop_reason),
440            Some(StopReason::MaxTokens)
441        );
442        let status = if is_truncated {
443            "incomplete"
444        } else {
445            "completed"
446        };
447        let incomplete_details = is_truncated.then(|| json!({ "reason": "max_output_tokens" }));
448        Ok(EncodedResponse {
449            body: embed_preservation(
450                json!({
451                    "id": response.id.clone().unwrap_or_else(|| "resp_switchyard".to_string()),
452                    "object": "response",
453                    "created_at": 0,
454                    "model": response.model.clone().unwrap_or_else(|| "unknown".to_string()),
455                    "status": status,
456                    "incomplete_details": incomplete_details,
457                    "output": encode_responses_output(&response.outputs),
458                    "usage": encode_responses_usage(&response.usage),
459                    "parallel_tool_calls": true,
460                    "tool_choice": "auto",
461                    "tools": [],
462                }),
463                &response.preservation,
464                _policy,
465            ),
466            diagnostics: Vec::new(),
467        })
468    }
469}
470
471/// Decodes Responses `input` into ordered normalized messages and inline
472/// instruction blocks.
473///
474/// Inline `system` and `developer` message items are returned as instruction
475/// blocks rather than conversation turns. Classifying them here, before any
476/// reasoning or tool-call state-machine transitions, prevents an instruction
477/// item from flushing pending reasoning or disturbing tool-call grouping.
478fn decode_responses_input(
479    value: &Value,
480    stored_state: bool,
481    custom_call_outputs: &mut Vec<String>,
482    diagnostics: &mut Vec<TranslationDiagnostic>,
483    policy: &TranslationPolicy,
484) -> Result<(Vec<Message>, Vec<InstructionBlock>)> {
485    match value {
486        Value::String(text) => Ok((vec![Message::text(Role::User, text)], Vec::new())),
487        Value::Array(items) => {
488            let mut messages = Vec::new();
489            let mut instructions = Vec::new();
490            let mut pending_tool_calls = Vec::new();
491            let mut pending_tool_outputs = Vec::new();
492            let mut deferred_messages = Vec::new();
493            let mut pending_reasoning: Vec<ContentBlock> = Vec::new();
494            for (index, item) in items.iter().enumerate() {
495                let Some(item) = item.as_object() else {
496                    push_lossy(
497                        diagnostics,
498                        policy,
499                        format!("Responses input item {index} is not an object"),
500                    )?;
501                    continue;
502                };
503                let item_type = match item.get("type") {
504                    Some(Value::String(item_type)) => Some(item_type.as_str()),
505                    Some(_) => {
506                        return Err(TranslationError::InvalidType {
507                            path: format!("$.input[{index}].type"),
508                            expected: "a string",
509                        });
510                    }
511                    None => None,
512                };
513                let is_message = item_type == Some("message")
514                    || (item_type.is_none()
515                        && item.contains_key("role")
516                        && item.contains_key("content"));
517                match item_type {
518                    _ if is_message => {
519                        let role = request_role_from_responses(
520                            item.get("role").and_then(Value::as_str),
521                            &format!("$.input[{index}].role"),
522                        )?;
523                        let content_value = item.get("content").unwrap_or(&Value::Null);
524                        if is_responses_builtin_tool_item(content_value) {
525                            return Err(TranslationError::InvalidValue {
526                                path: format!("$.input[{index}].content.type"),
527                                message:
528                                    "built-in tool items must be top-level Responses input items"
529                                        .to_string(),
530                            });
531                        }
532                        if let Some(content_index) = content_value.as_array().and_then(|blocks| {
533                            blocks.iter().position(is_responses_builtin_tool_item)
534                        }) {
535                            return Err(TranslationError::InvalidValue {
536                                path: format!("$.input[{index}].content[{content_index}].type"),
537                                message:
538                                    "built-in tool items must be top-level Responses input items"
539                                        .to_string(),
540                            });
541                        }
542                        let content = decode_responses_content(content_value);
543                        // Inline system and developer input items are instructions, not
544                        // conversation turns. Classify them before any state-machine
545                        // transitions so they do not flush pending reasoning or disturb
546                        // tool-call grouping.
547                        if matches!(role, Role::System | Role::Developer) {
548                            instructions.push(InstructionBlock { role, content });
549                            continue;
550                        }
551                        let mut content = content;
552                        // Reasoning that precedes an assistant message belongs to
553                        // that turn; fold it in so it never surfaces as its own
554                        // (empty-looking) assistant message downstream.
555                        if role == Role::Assistant
556                            && pending_tool_calls.is_empty()
557                            && !pending_reasoning.is_empty()
558                        {
559                            let mut merged = std::mem::take(&mut pending_reasoning);
560                            merged.append(&mut content);
561                            content = merged;
562                        }
563                        if role != Role::Assistant {
564                            flush_unattached_responses_reasoning(
565                                &mut messages,
566                                &mut pending_tool_calls,
567                                &mut pending_tool_outputs,
568                                &mut deferred_messages,
569                                &mut pending_reasoning,
570                            );
571                        }
572                        push_responses_non_tool_message(
573                            &mut messages,
574                            &mut pending_tool_calls,
575                            &mut pending_tool_outputs,
576                            &mut deferred_messages,
577                            &mut pending_reasoning,
578                            Message { role, content },
579                        );
580                    }
581                    Some("reasoning") => {
582                        // A reasoning item after a completed tool block opens the
583                        // next assistant turn: flush the finished block so this
584                        // reasoning attaches to the turn that produced it.
585                        if !pending_tool_outputs.is_empty() {
586                            flush_responses_tool_block(
587                                &mut messages,
588                                &mut pending_tool_calls,
589                                &mut pending_tool_outputs,
590                                &mut deferred_messages,
591                                &mut pending_reasoning,
592                            );
593                        }
594                        pending_reasoning.extend(decode_responses_reasoning_item(item));
595                    }
596                    Some("function_call") => {
597                        if !pending_tool_outputs.is_empty() {
598                            flush_responses_tool_block(
599                                &mut messages,
600                                &mut pending_tool_calls,
601                                &mut pending_tool_outputs,
602                                &mut deferred_messages,
603                                &mut pending_reasoning,
604                            );
605                        }
606                        let id = item
607                            .get("call_id")
608                            .and_then(Value::as_str)
609                            .filter(|id| !id.is_empty())
610                            .map(ToOwned::to_owned)
611                            .unwrap_or_else(|| match &policy.deterministic_ids {
612                                DeterministicIdPolicy::GenerateStable { prefix } => {
613                                    stable_id(prefix, index + 1)
614                                }
615                                DeterministicIdPolicy::Preserve => String::new(),
616                            });
617                        // History has to spell a tool the way its definition
618                        // does, or the transcript teaches the model a name the
619                        // upstream was never offered.
620                        let name = item
621                            .get("name")
622                            .and_then(Value::as_str)
623                            .unwrap_or_default()
624                            .to_string();
625                        let name = match item
626                            .get("namespace")
627                            .and_then(Value::as_str)
628                            .filter(|namespace| !namespace.is_empty())
629                        {
630                            Some(namespace) => {
631                                crate::codex_namespaces::qualified_tool_name(namespace, &name)
632                            }
633                            None => name,
634                        };
635                        pending_tool_calls.push(ToolCall {
636                            id,
637                            name,
638                            arguments: item.get("arguments").cloned().unwrap_or_else(|| json!({})),
639                        });
640                    }
641                    kind @ (Some("function_call_output") | Some("custom_tool_call_output")) => {
642                        let tool_call_id = item
643                            .get("call_id")
644                            .and_then(Value::as_str)
645                            .unwrap_or_default()
646                            .to_string();
647                        // A structured output keeps its typed text, image, and file parts;
648                        // any other shape rides through as one text block.
649                        let content = match item.get("output") {
650                            Some(output @ Value::Array(_)) => decode_responses_content(output),
651                            output => vec![ContentBlock::Text {
652                                text: output.map(json_string).unwrap_or_default(),
653                            }],
654                        };
655                        // An output whose call is not in this body answers a call the provider
656                        // holds behind `previous_response_id`; it stays a tool result so routing
657                        // sees a tool continuation, not a new user turn. Without stored state
658                        // the request is malformed, and the output becomes readable user text.
659                        let answers_pending_call = pending_tool_calls
660                            .iter()
661                            .any(|call| call.id == tool_call_id);
662                        if !answers_pending_call && !stored_state {
663                            let output_text = text_from_blocks(&content, " ");
664                            let message = Message::text(
665                                Role::User,
666                                format!("Tool result {tool_call_id}: {output_text}"),
667                            );
668                            push_responses_non_tool_message(
669                                &mut messages,
670                                &mut pending_tool_calls,
671                                &mut pending_tool_outputs,
672                                &mut deferred_messages,
673                                &mut pending_reasoning,
674                                message,
675                            );
676                            continue;
677                        }
678                        // The encoder types an output by the call it answers; a stored custom
679                        // output has no call in this body, so its type is carried separately.
680                        if !answers_pending_call && kind == Some("custom_tool_call_output") {
681                            custom_call_outputs.push(tool_call_id.clone());
682                        }
683                        pending_tool_outputs.push(ToolResult {
684                            tool_call_id,
685                            content,
686                            is_error: None,
687                        });
688                    }
689                    Some("custom_tool_call") => {
690                        // A freeform call carries a raw `input` string; it rides through the IR
691                        // as the single `input` argument of a function-style call.
692                        if !pending_tool_outputs.is_empty() {
693                            flush_responses_tool_block(
694                                &mut messages,
695                                &mut pending_tool_calls,
696                                &mut pending_tool_outputs,
697                                &mut deferred_messages,
698                                &mut pending_reasoning,
699                            );
700                        }
701                        let id = item
702                            .get("call_id")
703                            .and_then(Value::as_str)
704                            .filter(|id| !id.is_empty())
705                            .map(ToOwned::to_owned)
706                            .unwrap_or_else(|| match &policy.deterministic_ids {
707                                DeterministicIdPolicy::GenerateStable { prefix } => {
708                                    stable_id(prefix, index + 1)
709                                }
710                                DeterministicIdPolicy::Preserve => String::new(),
711                            });
712                        let name = item
713                            .get("name")
714                            .and_then(Value::as_str)
715                            .unwrap_or_default()
716                            .to_string();
717                        let input = item
718                            .get("input")
719                            .and_then(Value::as_str)
720                            .unwrap_or_default()
721                            .to_string();
722                        pending_tool_calls.push(ToolCall {
723                            id,
724                            name,
725                            arguments: json!({crate::codex_custom_tools::INPUT_ARGUMENT: input}),
726                        });
727                    }
728                    None => {
729                        return Err(TranslationError::InvalidValue {
730                            path: format!("$.input[{index}].type"),
731                            message: "missing type discriminator on a non-message input item"
732                                .to_string(),
733                        });
734                    }
735                    // Tool definitions, not conversation; decoded separately by the request
736                    // decoder and re-emitted in place by the request encoder.
737                    Some("additional_tools") => {}
738                    _ => {
739                        let message = Message {
740                            role: Role::User,
741                            content: vec![ContentBlock::Unknown {
742                                provider: WireFormat::OpenAiResponses.into(),
743                                raw: Value::Object(item.clone()),
744                            }],
745                        };
746                        flush_unattached_responses_reasoning(
747                            &mut messages,
748                            &mut pending_tool_calls,
749                            &mut pending_tool_outputs,
750                            &mut deferred_messages,
751                            &mut pending_reasoning,
752                        );
753                        push_responses_non_tool_message(
754                            &mut messages,
755                            &mut pending_tool_calls,
756                            &mut pending_tool_outputs,
757                            &mut deferred_messages,
758                            &mut pending_reasoning,
759                            message,
760                        );
761                    }
762                }
763            }
764            if !pending_tool_calls.is_empty() || !pending_tool_outputs.is_empty() {
765                flush_responses_tool_block(
766                    &mut messages,
767                    &mut pending_tool_calls,
768                    &mut pending_tool_outputs,
769                    &mut deferred_messages,
770                    &mut pending_reasoning,
771                );
772            }
773            // Trailing reasoning with no assistant turn to join keeps the
774            // historical standalone-message shape.
775            if !pending_reasoning.is_empty() {
776                messages.push(Message {
777                    role: Role::Assistant,
778                    content: pending_reasoning,
779                });
780            }
781            Ok((messages, instructions))
782        }
783        _ => Err(TranslationError::InvalidType {
784            path: "$.input".to_string(),
785            expected: "string or array",
786        }),
787    }
788}
789
790/// Places non-tool input items without breaking Responses tool-call adjacency.
791fn push_responses_non_tool_message(
792    messages: &mut Vec<Message>,
793    pending_tool_calls: &mut Vec<ToolCall>,
794    pending_tool_outputs: &mut Vec<ToolResult>,
795    deferred_messages: &mut Vec<Message>,
796    pending_reasoning: &mut Vec<ContentBlock>,
797    message: Message,
798) {
799    if !pending_tool_calls.is_empty() && pending_tool_outputs.is_empty() {
800        deferred_messages.push(message);
801        return;
802    }
803    if !pending_tool_calls.is_empty() || !pending_tool_outputs.is_empty() {
804        flush_responses_tool_block(
805            messages,
806            pending_tool_calls,
807            pending_tool_outputs,
808            deferred_messages,
809            pending_reasoning,
810        );
811    }
812    messages.push(message);
813}
814
815/// Emits reasoning that cannot join an assistant turn as a standalone assistant
816/// message at its original position. While tool calls are pending, the
817/// reasoning belongs to the in-flight turn and stays pending for the flush.
818fn flush_unattached_responses_reasoning(
819    messages: &mut Vec<Message>,
820    pending_tool_calls: &mut Vec<ToolCall>,
821    pending_tool_outputs: &mut Vec<ToolResult>,
822    deferred_messages: &mut Vec<Message>,
823    pending_reasoning: &mut Vec<ContentBlock>,
824) {
825    if pending_reasoning.is_empty() || !pending_tool_calls.is_empty() {
826        return;
827    }
828    let message = Message {
829        role: Role::Assistant,
830        content: std::mem::take(pending_reasoning),
831    };
832    push_responses_non_tool_message(
833        messages,
834        pending_tool_calls,
835        pending_tool_outputs,
836        deferred_messages,
837        pending_reasoning,
838        message,
839    );
840}
841
842/// Flushes pending function calls and matching outputs while preserving adjacency.
843/// Pending reasoning rides in the same assistant message as the tool calls so a
844/// Codex reasoning + function_call turn stays one assistant turn downstream.
845fn flush_responses_tool_block(
846    messages: &mut Vec<Message>,
847    pending_tool_calls: &mut Vec<ToolCall>,
848    pending_tool_outputs: &mut Vec<ToolResult>,
849    deferred_messages: &mut Vec<Message>,
850    pending_reasoning: &mut Vec<ContentBlock>,
851) {
852    if !pending_tool_calls.is_empty() {
853        let mut content = std::mem::take(pending_reasoning);
854        content.extend(
855            std::mem::take(pending_tool_calls)
856                .into_iter()
857                .map(ContentBlock::ToolCall),
858        );
859        messages.push(Message {
860            role: Role::Assistant,
861            content,
862        });
863    }
864
865    for output in std::mem::take(pending_tool_outputs) {
866        messages.push(Message {
867            role: Role::User,
868            content: vec![ContentBlock::ToolResult(output)],
869        });
870    }
871
872    messages.append(deferred_messages);
873}
874
875// Decodes a Responses reasoning item into private reasoning IR content.
876fn decode_responses_reasoning_item(item: &Map<String, Value>) -> Vec<ContentBlock> {
877    if let Some((text, signature)) = item
878        .get("encrypted_content")
879        .and_then(Value::as_str)
880        .and_then(super::decode_anthropic_thinking)
881    {
882        return vec![ContentBlock::Reasoning {
883            text,
884            signature: Some(signature),
885            details: Vec::new(),
886        }];
887    }
888    let mut parts = Vec::new();
889    collect_responses_reasoning_text(item.get("content"), &mut parts);
890    collect_responses_reasoning_text(item.get("summary"), &mut parts);
891    if let Some(text) = item.get("text").and_then(Value::as_str) {
892        parts.push(text.to_string());
893    }
894    // Encrypted reasoning must be replayed under the provider-issued item id;
895    // retain the original item only when it carries that opaque payload.
896    let details = if item.get("encrypted_content").is_some() {
897        vec![Value::Object(item.clone())]
898    } else {
899        Vec::new()
900    };
901    vec![ContentBlock::Reasoning {
902        text: parts.join("\n"),
903        signature: None,
904        details,
905    }]
906}
907
908// Decodes Responses content arrays or strings into normalized content blocks.
909fn decode_responses_content(value: &Value) -> Vec<ContentBlock> {
910    match value {
911        Value::String(text) => vec![ContentBlock::Text { text: text.clone() }],
912        Value::Array(blocks) => {
913            let mut out = Vec::new();
914            for block in blocks {
915                let Some(block) = block.as_object() else {
916                    continue;
917                };
918                match block.get("type").and_then(Value::as_str) {
919                    Some("input_text") | Some("output_text") | Some("text") => {
920                        out.push(ContentBlock::Text {
921                            text: block
922                                .get("text")
923                                .and_then(Value::as_str)
924                                .unwrap_or_default()
925                                .to_string(),
926                        });
927                    }
928                    Some("refusal") => out.push(ContentBlock::Refusal {
929                        text: block
930                            .get("refusal")
931                            .and_then(Value::as_str)
932                            .unwrap_or_default()
933                            .to_string(),
934                    }),
935                    Some("reasoning_text") | Some("summary_text") => {
936                        out.push(ContentBlock::Reasoning {
937                            text: block
938                                .get("text")
939                                .and_then(Value::as_str)
940                                .unwrap_or_default()
941                                .to_string(),
942                            signature: None,
943                            details: Vec::new(),
944                        });
945                    }
946                    Some("input_image") => {
947                        if let Some(source) = decode_image_source(block) {
948                            out.push(ContentBlock::Image { source });
949                        }
950                    }
951                    Some("input_audio") => out.push(ContentBlock::Audio {
952                        source: MediaSource::Raw(Value::Object(block.clone())),
953                    }),
954                    Some("input_file") => out.push(ContentBlock::File {
955                        source: decode_file_source(block),
956                    }),
957                    _ => out.push(ContentBlock::Unknown {
958                        provider: WireFormat::OpenAiResponses.into(),
959                        raw: Value::Object(block.clone()),
960                    }),
961                }
962            }
963            if out.is_empty() {
964                out.push(ContentBlock::Text {
965                    text: String::new(),
966                });
967            }
968            out
969        }
970        Value::Null => vec![ContentBlock::Text {
971            text: String::new(),
972        }],
973        other => vec![ContentBlock::Text {
974            text: string_value(other).unwrap_or_default(),
975        }],
976    }
977}
978
979// Decodes Responses role strings into normalized roles. Used for decoding
980// provider *responses*, which stay lenient — an unexpected role from upstream
981// is coerced to `user` rather than failing the whole response.
982fn role_from_responses(role: Option<&str>) -> Role {
983    match role {
984        Some("assistant") => Role::Assistant,
985        Some("system") => Role::System,
986        Some("developer") => Role::Developer,
987        _ => Role::User,
988    }
989}
990
991// Decodes a Responses *request* message role, enforcing the provider contract.
992//
993// Unlike `role_from_responses` (used for provider responses), request decoding
994// rejects an unknown role such as "api" with [`TranslationError::InvalidValue`]
995// so the router surfaces the same `invalid_value` error the provider would,
996// instead of silently coercing it to `user`. A missing role and
997// known-but-unmapped roles keep their historical mapping to `user`.
998fn request_role_from_responses(role: Option<&str>, path: &str) -> Result<Role> {
999    match role {
1000        Some("assistant") => Ok(Role::Assistant),
1001        Some("system") => Ok(Role::System),
1002        Some("developer") => Ok(Role::Developer),
1003        None => Ok(Role::User),
1004        Some(other) if is_known_role_name(other) => Ok(Role::User),
1005        Some(other) => Err(TranslationError::unsupported_role(path, other)),
1006    }
1007}
1008
1009// Decodes Responses tool shapes, including Codex-style tool entries.
1010// Decodes Responses tool shapes, including Codex-style tool entries.
1011//
1012// Codex groups tools into non-standard ``namespace`` containers that
1013// OpenAI-compatible upstreams do not accept. Each child is exposed under a
1014// `<namespace>__<tool>` name, and `namespaces` records the mapping so the
1015// response can split it back apart.
1016fn decode_responses_tools(
1017    value: Option<&Value>,
1018    namespaces: &mut Map<String, Value>,
1019    custom_tools: &mut Map<String, Value>,
1020) -> Vec<ToolDefinition> {
1021    let Some(tools) = value.and_then(Value::as_array) else {
1022        return Vec::new();
1023    };
1024    let mut out = Vec::new();
1025    for tool in tools {
1026        let Some(tool) = tool.as_object() else {
1027            continue;
1028        };
1029        if tool.get("type").and_then(Value::as_str) == Some("namespace") {
1030            // An unnamed container carries nothing to dispatch on, so its
1031            // children stay bare rather than gaining an empty prefix.
1032            let container = tool
1033                .get("name")
1034                .and_then(Value::as_str)
1035                .filter(|name| !name.is_empty());
1036            let description = tool.get("description").and_then(Value::as_str);
1037            for mut child in decode_responses_tools(tool.get("tools"), namespaces, custom_tools) {
1038                // A nested container already qualified its own children, and the
1039                // innermost name is the one that identifies the tool.
1040                let already_qualified = namespaces.contains_key(&child.name);
1041                if let Some(container) = container
1042                    && !already_qualified
1043                {
1044                    let qualified =
1045                        crate::codex_namespaces::qualified_tool_name(container, &child.name);
1046                    crate::codex_namespaces::record_tool_namespace(
1047                        namespaces,
1048                        &qualified,
1049                        container,
1050                        description,
1051                    );
1052                    child.name = qualified;
1053                }
1054                out.push(child);
1055            }
1056        } else if tool.get("type").and_then(Value::as_str) == Some("custom") {
1057            // A freeform tool takes a raw string, not JSON arguments. The IR sees it as a
1058            // function with a single `input` argument; the verbatim definition is kept so a
1059            // Responses upstream still receives the freeform tool. The definition is keyed by
1060            // the tool's own name: Codex defines its freeform tools at the top level, so a
1061            // custom tool nested in a namespace container (which would be renamed above) is
1062            // not restored verbatim and goes upstream as the equivalent function tool.
1063            if let Some(name) = tool
1064                .get("name")
1065                .and_then(Value::as_str)
1066                .filter(|name| !name.is_empty())
1067            {
1068                custom_tools.insert(name.to_string(), Value::Object(tool.clone()));
1069                out.push(ToolDefinition {
1070                    name: name.to_string(),
1071                    description: tool
1072                        .get("description")
1073                        .and_then(Value::as_str)
1074                        .map(ToOwned::to_owned),
1075                    parameters: crate::codex_custom_tools::input_schema(),
1076                    strict: None,
1077                });
1078            }
1079        } else if tool.get("type").and_then(Value::as_str) == Some("function") {
1080            if let Some(function) = tool.get("function").and_then(Value::as_object) {
1081                if let Some(name) = function.get("name").and_then(Value::as_str)
1082                    && !name.is_empty()
1083                {
1084                    out.push(ToolDefinition {
1085                        name: name.to_string(),
1086                        description: function
1087                            .get("description")
1088                            .and_then(Value::as_str)
1089                            .map(ToOwned::to_owned),
1090                        parameters: function
1091                            .get("parameters")
1092                            .cloned()
1093                            .unwrap_or_else(|| json!({})),
1094                        strict: function.get("strict").and_then(Value::as_bool),
1095                    });
1096                }
1097            } else {
1098                if !push_responses_function_tool(&mut out, tool) {
1099                    push_responses_id_tool(&mut out, tool);
1100                }
1101            }
1102        } else if tool.get("type").is_none() && tool.contains_key("name") {
1103            push_responses_function_tool(&mut out, tool);
1104        } else {
1105            push_responses_id_tool(&mut out, tool);
1106        }
1107    }
1108    out
1109}
1110
1111// Adds an OpenAI-style function tool to the normalized tool list.
1112fn push_responses_function_tool(
1113    out: &mut Vec<ToolDefinition>,
1114    tool: &serde_json::Map<String, Value>,
1115) -> bool {
1116    let Some(name) = tool.get("name").and_then(Value::as_str) else {
1117        return false;
1118    };
1119    if name.is_empty() {
1120        return false;
1121    }
1122
1123    out.push(ToolDefinition {
1124        name: name.to_string(),
1125        description: tool
1126            .get("description")
1127            .and_then(Value::as_str)
1128            .map(ToOwned::to_owned),
1129        parameters: tool.get("parameters").cloned().unwrap_or_else(|| json!({})),
1130        strict: tool.get("strict").and_then(Value::as_bool),
1131    });
1132    true
1133}
1134
1135// Adds a Codex-style ID tool to the normalized tool list.
1136fn push_responses_id_tool(
1137    out: &mut Vec<ToolDefinition>,
1138    tool: &serde_json::Map<String, Value>,
1139) -> bool {
1140    let Some(id) = tool.get("id").and_then(Value::as_str) else {
1141        return false;
1142    };
1143    if id.is_empty() {
1144        return false;
1145    }
1146
1147    out.push(ToolDefinition {
1148        name: id.to_string(),
1149        description: tool
1150            .get("description")
1151            .and_then(Value::as_str)
1152            .map(ToOwned::to_owned),
1153        parameters: tool
1154            .get("inputSchema")
1155            .and_then(Value::as_object)
1156            .and_then(|schema| schema.get("jsonSchema"))
1157            .cloned()
1158            .unwrap_or_else(|| json!({})),
1159        strict: None,
1160    });
1161    true
1162}
1163
1164// Decodes Responses tool-choice values into normalized policy.
1165fn decode_responses_tool_choice(value: &Value) -> Option<ToolChoice> {
1166    match value {
1167        Value::String(text) if text == "auto" => Some(ToolChoice::Auto),
1168        Value::String(text) if text == "required" => Some(ToolChoice::Required),
1169        Value::String(text) if text == "none" => Some(ToolChoice::None),
1170        Value::Object(object) if object.get("type").and_then(Value::as_str) == Some("function") => {
1171            // A forced tool has to name the same thing its definition does, or
1172            // the upstream rejects a choice naming a tool it was never offered.
1173            object.get("name").and_then(Value::as_str).map(|name| {
1174                match object
1175                    .get("namespace")
1176                    .and_then(Value::as_str)
1177                    .filter(|namespace| !namespace.is_empty())
1178                {
1179                    Some(namespace) => ToolChoice::Tool {
1180                        name: crate::codex_namespaces::qualified_tool_name(namespace, name),
1181                    },
1182                    None => ToolChoice::Tool {
1183                        name: name.to_string(),
1184                    },
1185                }
1186            })
1187        }
1188        Value::Object(object)
1189            if object.get("type").and_then(Value::as_str) == Some("allowed_tools") =>
1190        {
1191            Some(ToolChoice::Raw(value.clone()))
1192        }
1193        Value::Object(_) => None,
1194        _ => Some(ToolChoice::Raw(value.clone())),
1195    }
1196}
1197
1198// Maps Responses text format options into Chat-compatible response_format JSON.
1199fn decode_responses_text_format(value: Option<&Value>) -> Option<Value> {
1200    let format = value
1201        .and_then(Value::as_object)
1202        .and_then(|object| object.get("format"))
1203        .and_then(Value::as_object)?;
1204
1205    match format.get("type").and_then(Value::as_str) {
1206        Some("json_schema") => {
1207            if let Some(json_schema) = format.get("json_schema").and_then(Value::as_object) {
1208                return Some(json!({
1209                    "type": "json_schema",
1210                    "json_schema": Value::Object(json_schema.clone()),
1211                }));
1212            }
1213
1214            let mut json_schema = Map::new();
1215            for field in ["name", "description", "schema", "strict"] {
1216                if let Some(value) = format.get(field) {
1217                    json_schema.insert(field.to_string(), value.clone());
1218                }
1219            }
1220            (!json_schema.is_empty()).then_some(json!({
1221                "type": "json_schema",
1222                "json_schema": Value::Object(json_schema),
1223            }))
1224        }
1225        Some("json_object") => Some(json!({"type": "json_object"})),
1226        Some("text") => Some(json!({"type": "text"})),
1227        _ => None,
1228    }
1229}
1230
1231// Flattens Chat-compatible JSON schema fields into the Responses text format shape.
1232fn encode_responses_text_format(response_format: &Value) -> Value {
1233    let Some(format) = response_format.as_object() else {
1234        return response_format.clone();
1235    };
1236    let Some(response_type) = format
1237        .get("type")
1238        .filter(|value| value.as_str() == Some("json_schema"))
1239    else {
1240        return response_format.clone();
1241    };
1242
1243    let Some(Value::Object(json_schema)) = format.get("json_schema") else {
1244        return response_format.clone();
1245    };
1246    if !matches!(json_schema.get("name"), Some(Value::String(_)))
1247        || !matches!(json_schema.get("schema"), Some(Value::Object(_)))
1248        || json_schema
1249            .get("description")
1250            .is_some_and(|value| !value.is_string())
1251        || json_schema
1252            .get("strict")
1253            .is_some_and(|value| !value.is_boolean())
1254    {
1255        return response_format.clone();
1256    }
1257
1258    let mut output = Map::new();
1259    output.insert("type".to_string(), response_type.clone());
1260    for field in ["name", "description", "schema", "strict"] {
1261        if let Some(value) = json_schema.get(field) {
1262            output.insert(field.to_string(), value.clone());
1263        }
1264    }
1265    Value::Object(output)
1266}
1267
1268// Encodes normalized messages into the Responses `input` shape.
1269fn encode_responses_input(
1270    messages: &[Message],
1271    diagnostics: &mut Vec<TranslationDiagnostic>,
1272    policy: &TranslationPolicy,
1273    namespaces: Option<&Map<String, Value>>,
1274    custom_tools: &std::collections::HashSet<String>,
1275    mut custom_call_ids: std::collections::HashSet<String>,
1276) -> Result<Value> {
1277    // Call ids of freeform tool calls emitted by this pass, seeded with the ids of stored-state
1278    // custom outputs. A tool result is typed by the call it answers, and Responses history
1279    // always lists the call before its output, so recording ids as calls are encoded is enough
1280    // to type the outputs that follow.
1281    if messages.len() == 1
1282        && matches!(messages[0].role, Role::User)
1283        && messages[0].content.len() == 1
1284        && matches!(messages[0].content[0], ContentBlock::Text { .. })
1285        && let ContentBlock::Text { text } = &messages[0].content[0]
1286    {
1287        return Ok(Value::String(text.clone()));
1288    }
1289    let mut encoded = Vec::new();
1290    // Some upstream translators (Kimi K3) resolve a tool output by an explicit
1291    // `name`, so pair every output with the name of the call it answers.
1292    let mut call_names: HashMap<&str, &str> = HashMap::new();
1293    for message in messages {
1294        for block in &message.content {
1295            if let ContentBlock::ToolCall(call) = block {
1296                call_names.insert(call.id.as_str(), call.name.as_str());
1297            }
1298        }
1299    }
1300    for message in messages {
1301        // The decoder carries each valid top-level built-in tool item as one
1302        // provider-qualified block, so replay that block in place.
1303        if message.role == Role::User
1304            && 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}