Skip to main content

switchyard_translation/codecs/responses/
buffered.rs

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