Skip to main content

switchyard_translation/codecs/responses/
buffered.rs

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