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