Skip to main content

switchyard_translation/codecs/responses/
buffered.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Buffered codec for OpenAI Responses request and response JSON.
5
6use std::collections::HashSet;
7
8use serde_json::{json, Map, Value};
9
10use crate::codecs::common::{
11    is_known_role_name, provider_extensions, reasoning_text_from_blocks, text_from_blocks,
12};
13use crate::codecs::openai_chat::{decode_file_source, decode_image_source};
14use crate::codecs::{
15    DecodedRequest, DecodedResponse, EncodedRequest, EncodedResponse, FormatCodec,
16};
17use crate::diagnostic::TranslationDiagnostic;
18use crate::error::{Result, TranslationError};
19use crate::format::{FormatId, WireFormat};
20use crate::llm::{
21    ContentBlock, LlmRequest, LlmResponse, MediaSource, Message, OutputParams, ProviderExtensions,
22    ReasoningParams, ResponseOutput, Role, SamplingParams, StopReason, ToolCall, ToolChoice,
23    ToolDefinition, ToolResult, Usage,
24};
25use crate::policy::{DeterministicIdPolicy, TranslationPolicy};
26use crate::util::{
27    capture_request_preservation, capture_response_preservation, embed_preservation,
28    exact_preserved_request, exact_preserved_response, json_string, push_lossy, stable_id,
29    string_value, validate_request_capabilities,
30};
31
32/// Format codec for OpenAI Responses payloads.
33pub struct OpenAiResponsesCodec;
34
35impl FormatCodec for OpenAiResponsesCodec {
36    fn format(&self) -> FormatId {
37        WireFormat::OpenAiResponses.into()
38    }
39
40    fn decode_request(&self, body: &Value, policy: &TranslationPolicy) -> Result<DecodedRequest> {
41        let body = crate::util::object(body, "$")?;
42        let mut diagnostics = Vec::new();
43        let mut request = LlmRequest {
44            model: body
45                .get("model")
46                .and_then(Value::as_str)
47                .filter(|model| !model.is_empty())
48                .map(ToOwned::to_owned),
49            output: OutputParams {
50                max_output_tokens: body.get("max_output_tokens").and_then(Value::as_u64),
51                response_format: decode_responses_text_format(body.get("text")),
52            },
53            reasoning: ReasoningParams {
54                effort: body
55                    .get("reasoning")
56                    .and_then(Value::as_object)
57                    .and_then(|object| object.get("effort"))
58                    .and_then(Value::as_str)
59                    .map(ToOwned::to_owned),
60                raw: body.get("reasoning").cloned(),
61            },
62            sampling: SamplingParams {
63                temperature: body.get("temperature").and_then(Value::as_f64),
64                top_p: body.get("top_p").and_then(Value::as_f64),
65                top_k: None,
66            },
67            stream: body.get("stream").and_then(Value::as_bool).unwrap_or(false),
68            preservation: capture_request_preservation(
69                WireFormat::OpenAiResponses,
70                &Value::Object(body.clone()),
71                policy,
72            ),
73            ..LlmRequest::default()
74        };
75        if let Some(instructions) = body.get("instructions").and_then(Value::as_str) {
76            if !instructions.is_empty() {
77                request.instructions.push(crate::llm::InstructionBlock {
78                    role: Role::System,
79                    content: vec![ContentBlock::Text {
80                        text: instructions.to_string(),
81                    }],
82                });
83            }
84        }
85        request.messages = decode_responses_input(
86            body.get("input").unwrap_or(&Value::String(String::new())),
87            &mut diagnostics,
88            policy,
89        )?;
90        request.tools = decode_responses_tools(body.get("tools"));
91        request.tool_choice = body
92            .get("tool_choice")
93            .and_then(decode_responses_tool_choice);
94        request.extensions.fields = provider_extensions(
95            body,
96            &[
97                "model",
98                "instructions",
99                "input",
100                "tools",
101                "tool_choice",
102                "max_output_tokens",
103                "text",
104                "reasoning",
105                "temperature",
106                "top_p",
107                "stream",
108            ],
109        );
110        Ok(DecodedRequest {
111            request,
112            diagnostics,
113        })
114    }
115
116    fn encode_request(
117        &self,
118        request: &LlmRequest,
119        _policy: &TranslationPolicy,
120    ) -> Result<EncodedRequest> {
121        if let Some(body) =
122            exact_preserved_request(&request.preservation, WireFormat::OpenAiResponses, _policy)
123        {
124            return Ok(EncodedRequest {
125                body,
126                diagnostics: Vec::new(),
127            });
128        }
129        let mut diagnostics = Vec::new();
130        validate_request_capabilities(request, &mut diagnostics, _policy)?;
131        let mut body = Map::new();
132        if let Some(model) = &request.model {
133            body.insert("model".to_string(), Value::String(model.clone()));
134        }
135        let instructions = request
136            .instructions
137            .iter()
138            .flat_map(|instruction| instruction.content.iter())
139            .filter_map(|block| match block {
140                ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
141                _ => None,
142            })
143            .collect::<Vec<_>>()
144            .join("\n\n");
145        if !instructions.is_empty() {
146            body.insert("instructions".to_string(), Value::String(instructions));
147        }
148        body.insert(
149            "input".to_string(),
150            encode_responses_input(&request.messages, &mut diagnostics, _policy)?,
151        );
152        if !request.tools.is_empty() {
153            body.insert("tools".to_string(), encode_responses_tools(&request.tools));
154        }
155        if let Some(choice) = &request.tool_choice {
156            if let Some(choice) = encode_responses_tool_choice(choice) {
157                body.insert("tool_choice".to_string(), choice);
158            }
159        }
160        if let Some(max_output_tokens) = request.output.max_output_tokens {
161            body.insert("max_output_tokens".to_string(), json!(max_output_tokens));
162        }
163        if let Some(response_format) = &request.output.response_format {
164            body.insert("text".to_string(), json!({"format": response_format}));
165        }
166        if let Some(effort) = &request.reasoning.effort {
167            body.insert("reasoning".to_string(), json!({"effort": effort}));
168        }
169        if let Some(value) = request.sampling.temperature {
170            body.insert("temperature".to_string(), json!(value));
171        }
172        if let Some(value) = request.sampling.top_p {
173            body.insert("top_p".to_string(), json!(value));
174        }
175        if request.stream {
176            body.insert("stream".to_string(), Value::Bool(true));
177        }
178        let body = embed_preservation(Value::Object(body), &request.preservation, _policy);
179        Ok(EncodedRequest { body, diagnostics })
180    }
181
182    fn decode_response(&self, body: &Value, policy: &TranslationPolicy) -> Result<DecodedResponse> {
183        let body = crate::util::object(body, "$")?;
184        let mut diagnostics = Vec::new();
185        let mut outputs = Vec::new();
186        if let Some(items) = body.get("output").and_then(Value::as_array) {
187            for item in items {
188                if let Some(output) = decode_responses_output_item(item, &mut diagnostics, policy)?
189                {
190                    outputs.push(output);
191                }
192            }
193        }
194        if outputs.is_empty() {
195            outputs.push(ResponseOutput {
196                role: Role::Assistant,
197                content: vec![ContentBlock::Text {
198                    text: String::new(),
199                }],
200                stop_reason: Some(StopReason::EndTurn),
201            });
202        }
203        let response = LlmResponse {
204            id: body
205                .get("id")
206                .and_then(Value::as_str)
207                .map(ToOwned::to_owned),
208            model: body
209                .get("model")
210                .and_then(Value::as_str)
211                .map(ToOwned::to_owned),
212            outputs,
213            usage: decode_responses_usage(body.get("usage")),
214            extensions: ProviderExtensions {
215                fields: provider_extensions(
216                    body,
217                    &["id", "model", "object", "output", "usage", "status"],
218                ),
219            },
220            preservation: capture_response_preservation(
221                WireFormat::OpenAiResponses,
222                &Value::Object(body.clone()),
223                policy,
224            ),
225        };
226        Ok(DecodedResponse {
227            response,
228            diagnostics,
229        })
230    }
231
232    fn encode_response(
233        &self,
234        response: &LlmResponse,
235        _policy: &TranslationPolicy,
236    ) -> Result<EncodedResponse> {
237        if let Some(body) =
238            exact_preserved_response(&response.preservation, WireFormat::OpenAiResponses, _policy)
239        {
240            return Ok(EncodedResponse {
241                body,
242                diagnostics: Vec::new(),
243            });
244        }
245        Ok(EncodedResponse {
246            body: embed_preservation(
247                json!({
248                    "id": response.id.clone().unwrap_or_else(|| "resp_switchyard".to_string()),
249                    "object": "response",
250                    "created_at": 0,
251                    "model": response.model.clone().unwrap_or_else(|| "unknown".to_string()),
252                    "status": "completed",
253                    "output": encode_responses_output(&response.outputs),
254                    "usage": encode_responses_usage(&response.usage),
255                    "parallel_tool_calls": true,
256                    "tool_choice": "auto",
257                    "tools": [],
258                }),
259                &response.preservation,
260                _policy,
261            ),
262            diagnostics: Vec::new(),
263        })
264    }
265}
266
267// Decodes Responses `input` into ordered normalized messages.
268fn decode_responses_input(
269    value: &Value,
270    diagnostics: &mut Vec<TranslationDiagnostic>,
271    policy: &TranslationPolicy,
272) -> Result<Vec<Message>> {
273    match value {
274        Value::String(text) => Ok(vec![Message::text(Role::User, text)]),
275        Value::Array(items) => {
276            let mut messages = Vec::new();
277            let mut pending_tool_calls = Vec::new();
278            let mut pending_tool_outputs = Vec::new();
279            let mut deferred_messages = Vec::new();
280            for (index, item) in items.iter().enumerate() {
281                let Some(item) = item.as_object() else {
282                    push_lossy(
283                        diagnostics,
284                        policy,
285                        format!("Responses input item {index} is not an object"),
286                    )?;
287                    continue;
288                };
289                match item.get("type").and_then(Value::as_str) {
290                    Some("message") => {
291                        let message = Message {
292                            role: request_role_from_responses(
293                                item.get("role").and_then(Value::as_str),
294                                &format!("$.input[{index}].role"),
295                            )?,
296                            content: decode_responses_content(
297                                item.get("content").unwrap_or(&Value::Null),
298                            ),
299                        };
300                        push_responses_non_tool_message(
301                            &mut messages,
302                            &mut pending_tool_calls,
303                            &mut pending_tool_outputs,
304                            &mut deferred_messages,
305                            message,
306                        );
307                    }
308                    Some("reasoning") => {
309                        let message = Message {
310                            role: Role::Assistant,
311                            content: decode_responses_reasoning_item(item),
312                        };
313                        push_responses_non_tool_message(
314                            &mut messages,
315                            &mut pending_tool_calls,
316                            &mut pending_tool_outputs,
317                            &mut deferred_messages,
318                            message,
319                        );
320                    }
321                    Some("function_call") => {
322                        if !pending_tool_outputs.is_empty() {
323                            flush_responses_tool_block(
324                                &mut messages,
325                                &mut pending_tool_calls,
326                                &mut pending_tool_outputs,
327                                &mut deferred_messages,
328                            );
329                        }
330                        let id = item
331                            .get("call_id")
332                            .and_then(Value::as_str)
333                            .filter(|id| !id.is_empty())
334                            .map(ToOwned::to_owned)
335                            .unwrap_or_else(|| match &policy.deterministic_ids {
336                                DeterministicIdPolicy::GenerateStable { prefix } => {
337                                    stable_id(prefix, index + 1)
338                                }
339                                DeterministicIdPolicy::Preserve => String::new(),
340                            });
341                        pending_tool_calls.push(ToolCall {
342                            id,
343                            name: item
344                                .get("name")
345                                .and_then(Value::as_str)
346                                .unwrap_or_default()
347                                .to_string(),
348                            arguments: item.get("arguments").cloned().unwrap_or_else(|| json!({})),
349                        });
350                    }
351                    Some("function_call_output") => {
352                        let tool_call_id = item
353                            .get("call_id")
354                            .and_then(Value::as_str)
355                            .unwrap_or_default()
356                            .to_string();
357                        let output_text = item.get("output").map(json_string).unwrap_or_default();
358                        pending_tool_outputs.push(ToolResult {
359                            tool_call_id,
360                            content: vec![ContentBlock::Text { text: output_text }],
361                            is_error: None,
362                        });
363                    }
364                    _ => {
365                        let message = Message {
366                            role: Role::User,
367                            content: vec![ContentBlock::Unknown {
368                                provider: WireFormat::OpenAiResponses.into(),
369                                raw: Value::Object(item.clone()),
370                            }],
371                        };
372                        push_responses_non_tool_message(
373                            &mut messages,
374                            &mut pending_tool_calls,
375                            &mut pending_tool_outputs,
376                            &mut deferred_messages,
377                            message,
378                        );
379                    }
380                }
381            }
382            if !pending_tool_calls.is_empty() || !pending_tool_outputs.is_empty() {
383                flush_responses_tool_block(
384                    &mut messages,
385                    &mut pending_tool_calls,
386                    &mut pending_tool_outputs,
387                    &mut deferred_messages,
388                );
389            }
390            Ok(messages)
391        }
392        other => Ok(vec![Message::text(
393            Role::User,
394            string_value(other).unwrap_or_default(),
395        )]),
396    }
397}
398
399// Places non-tool input items without breaking Responses tool-call adjacency.
400fn push_responses_non_tool_message(
401    messages: &mut Vec<Message>,
402    pending_tool_calls: &mut Vec<ToolCall>,
403    pending_tool_outputs: &mut Vec<ToolResult>,
404    deferred_messages: &mut Vec<Message>,
405    message: Message,
406) {
407    if !pending_tool_calls.is_empty() && pending_tool_outputs.is_empty() {
408        deferred_messages.push(message);
409        return;
410    }
411    if !pending_tool_calls.is_empty() || !pending_tool_outputs.is_empty() {
412        flush_responses_tool_block(
413            messages,
414            pending_tool_calls,
415            pending_tool_outputs,
416            deferred_messages,
417        );
418    }
419    messages.push(message);
420}
421
422// Flushes pending function calls and matching outputs while preserving adjacency.
423fn flush_responses_tool_block(
424    messages: &mut Vec<Message>,
425    pending_tool_calls: &mut Vec<ToolCall>,
426    pending_tool_outputs: &mut Vec<ToolResult>,
427    deferred_messages: &mut Vec<Message>,
428) {
429    let tool_call_ids = pending_tool_calls
430        .iter()
431        .map(|call| call.id.clone())
432        .collect::<HashSet<_>>();
433
434    if !pending_tool_calls.is_empty() {
435        messages.push(Message {
436            role: Role::Assistant,
437            content: std::mem::take(pending_tool_calls)
438                .into_iter()
439                .map(ContentBlock::ToolCall)
440                .collect(),
441        });
442    }
443
444    for output in std::mem::take(pending_tool_outputs) {
445        if tool_call_ids.contains(&output.tool_call_id) {
446            messages.push(Message {
447                role: Role::User,
448                content: vec![ContentBlock::ToolResult(output)],
449            });
450        } else {
451            let tool_call_id = output.tool_call_id;
452            let output_text = text_from_blocks(&output.content, " ");
453            messages.push(Message::text(
454                Role::User,
455                format!("Tool result {tool_call_id}: {output_text}"),
456            ));
457        }
458    }
459
460    messages.append(deferred_messages);
461}
462
463// Decodes a Responses reasoning item into private reasoning IR content.
464fn decode_responses_reasoning_item(item: &Map<String, Value>) -> Vec<ContentBlock> {
465    let mut parts = Vec::new();
466    collect_responses_reasoning_text(item.get("content"), &mut parts);
467    collect_responses_reasoning_text(item.get("summary"), &mut parts);
468    if let Some(text) = item.get("text").and_then(Value::as_str) {
469        parts.push(text.to_string());
470    }
471    vec![ContentBlock::Reasoning {
472        text: parts.join("\n"),
473        signature: None,
474    }]
475}
476
477// Collects text from the known Responses reasoning content/summary shapes.
478fn collect_responses_reasoning_text(value: Option<&Value>, out: &mut Vec<String>) {
479    match value {
480        Some(Value::String(text)) if !text.is_empty() => out.push(text.clone()),
481        Some(Value::Array(items)) => {
482            for item in items {
483                match item {
484                    Value::String(text) if !text.is_empty() => out.push(text.clone()),
485                    Value::Object(object) => {
486                        if matches!(
487                            object.get("type").and_then(Value::as_str),
488                            Some("reasoning_text" | "summary_text" | "text")
489                        ) {
490                            if let Some(text) = object.get("text").and_then(Value::as_str) {
491                                if !text.is_empty() {
492                                    out.push(text.to_string());
493                                }
494                            }
495                        }
496                    }
497                    _ => {}
498                }
499            }
500        }
501        _ => {}
502    }
503}
504
505// Decodes Responses content arrays or strings into normalized content blocks.
506fn decode_responses_content(value: &Value) -> Vec<ContentBlock> {
507    match value {
508        Value::String(text) => vec![ContentBlock::Text { text: text.clone() }],
509        Value::Array(blocks) => {
510            let mut out = Vec::new();
511            for block in blocks {
512                let Some(block) = block.as_object() else {
513                    continue;
514                };
515                match block.get("type").and_then(Value::as_str) {
516                    Some("input_text") | Some("output_text") | Some("text") => {
517                        out.push(ContentBlock::Text {
518                            text: block
519                                .get("text")
520                                .and_then(Value::as_str)
521                                .unwrap_or_default()
522                                .to_string(),
523                        });
524                    }
525                    Some("refusal") => out.push(ContentBlock::Refusal {
526                        text: block
527                            .get("refusal")
528                            .and_then(Value::as_str)
529                            .unwrap_or_default()
530                            .to_string(),
531                    }),
532                    Some("reasoning_text") | Some("summary_text") => {
533                        out.push(ContentBlock::Reasoning {
534                            text: block
535                                .get("text")
536                                .and_then(Value::as_str)
537                                .unwrap_or_default()
538                                .to_string(),
539                            signature: None,
540                        });
541                    }
542                    Some("input_image") => {
543                        if let Some(source) = decode_image_source(block) {
544                            out.push(ContentBlock::Image { source });
545                        }
546                    }
547                    Some("input_file") => out.push(ContentBlock::File {
548                        source: decode_file_source(block),
549                    }),
550                    _ => out.push(ContentBlock::Unknown {
551                        provider: WireFormat::OpenAiResponses.into(),
552                        raw: Value::Object(block.clone()),
553                    }),
554                }
555            }
556            if out.is_empty() {
557                out.push(ContentBlock::Text {
558                    text: String::new(),
559                });
560            }
561            out
562        }
563        Value::Null => vec![ContentBlock::Text {
564            text: String::new(),
565        }],
566        other => vec![ContentBlock::Text {
567            text: string_value(other).unwrap_or_default(),
568        }],
569    }
570}
571
572// Decodes Responses role strings into normalized roles. Used for decoding
573// provider *responses*, which stay lenient — an unexpected role from upstream
574// is coerced to `user` rather than failing the whole response.
575fn role_from_responses(role: Option<&str>) -> Role {
576    match role {
577        Some("assistant") => Role::Assistant,
578        Some("system") => Role::System,
579        Some("developer") => Role::Developer,
580        _ => Role::User,
581    }
582}
583
584// Decodes a Responses *request* message role, enforcing the provider contract.
585//
586// Unlike `role_from_responses` (used for provider responses), request decoding
587// rejects an unknown role such as "api" with [`TranslationError::InvalidValue`]
588// so the router surfaces the same `invalid_value` error the provider would,
589// instead of silently coercing it to `user`. A missing role and
590// known-but-unmapped roles keep their historical mapping to `user`.
591fn request_role_from_responses(role: Option<&str>, path: &str) -> Result<Role> {
592    match role {
593        Some("assistant") => Ok(Role::Assistant),
594        Some("system") => Ok(Role::System),
595        Some("developer") => Ok(Role::Developer),
596        None => Ok(Role::User),
597        Some(other) if is_known_role_name(other) => Ok(Role::User),
598        Some(other) => Err(TranslationError::unsupported_role(path, other)),
599    }
600}
601
602// Decodes Responses tool shapes, including Codex-style tool entries.
603fn decode_responses_tools(value: Option<&Value>) -> Vec<ToolDefinition> {
604    let Some(tools) = value.and_then(Value::as_array) else {
605        return Vec::new();
606    };
607    let mut out = Vec::new();
608    for tool in tools {
609        let Some(tool) = tool.as_object() else {
610            continue;
611        };
612        if tool.get("type").and_then(Value::as_str) == Some("function") {
613            if let Some(function) = tool.get("function").and_then(Value::as_object) {
614                if let Some(name) = function.get("name").and_then(Value::as_str) {
615                    if !name.is_empty() {
616                        out.push(ToolDefinition {
617                            name: name.to_string(),
618                            description: function
619                                .get("description")
620                                .and_then(Value::as_str)
621                                .map(ToOwned::to_owned),
622                            parameters: function
623                                .get("parameters")
624                                .cloned()
625                                .unwrap_or_else(|| json!({})),
626                            strict: function.get("strict").and_then(Value::as_bool),
627                        });
628                    }
629                }
630            } else {
631                if !push_responses_function_tool(&mut out, tool) {
632                    push_responses_id_tool(&mut out, tool);
633                }
634            }
635        } else if tool.get("type").is_none() && tool.contains_key("name") {
636            push_responses_function_tool(&mut out, tool);
637        } else {
638            push_responses_id_tool(&mut out, tool);
639        }
640    }
641    out
642}
643
644// Adds an OpenAI-style function tool to the normalized tool list.
645fn push_responses_function_tool(
646    out: &mut Vec<ToolDefinition>,
647    tool: &serde_json::Map<String, Value>,
648) -> bool {
649    let Some(name) = tool.get("name").and_then(Value::as_str) else {
650        return false;
651    };
652    if name.is_empty() {
653        return false;
654    }
655
656    out.push(ToolDefinition {
657        name: name.to_string(),
658        description: tool
659            .get("description")
660            .and_then(Value::as_str)
661            .map(ToOwned::to_owned),
662        parameters: tool.get("parameters").cloned().unwrap_or_else(|| json!({})),
663        strict: tool.get("strict").and_then(Value::as_bool),
664    });
665    true
666}
667
668// Adds a Codex-style ID tool to the normalized tool list.
669fn push_responses_id_tool(
670    out: &mut Vec<ToolDefinition>,
671    tool: &serde_json::Map<String, Value>,
672) -> bool {
673    let Some(id) = tool.get("id").and_then(Value::as_str) else {
674        return false;
675    };
676    if id.is_empty() {
677        return false;
678    }
679
680    out.push(ToolDefinition {
681        name: id.to_string(),
682        description: tool
683            .get("description")
684            .and_then(Value::as_str)
685            .map(ToOwned::to_owned),
686        parameters: tool
687            .get("inputSchema")
688            .and_then(Value::as_object)
689            .and_then(|schema| schema.get("jsonSchema"))
690            .cloned()
691            .unwrap_or_else(|| json!({})),
692        strict: None,
693    });
694    true
695}
696
697// Decodes Responses tool-choice values into normalized policy.
698fn decode_responses_tool_choice(value: &Value) -> Option<ToolChoice> {
699    match value {
700        Value::String(text) if text == "auto" => Some(ToolChoice::Auto),
701        Value::String(text) if text == "required" => Some(ToolChoice::Required),
702        Value::String(text) if text == "none" => Some(ToolChoice::None),
703        Value::Object(object) if object.get("type").and_then(Value::as_str) == Some("function") => {
704            object
705                .get("name")
706                .and_then(Value::as_str)
707                .map(|name| ToolChoice::Tool {
708                    name: name.to_string(),
709                })
710        }
711        Value::Object(_) => None,
712        _ => Some(ToolChoice::Raw(value.clone())),
713    }
714}
715
716// Maps Responses text format options into Chat-compatible response_format JSON.
717fn decode_responses_text_format(value: Option<&Value>) -> Option<Value> {
718    let format = value
719        .and_then(Value::as_object)
720        .and_then(|object| object.get("format"))
721        .and_then(Value::as_object)?;
722
723    match format.get("type").and_then(Value::as_str) {
724        Some("json_schema") => {
725            if let Some(json_schema) = format.get("json_schema").and_then(Value::as_object) {
726                return Some(json!({
727                    "type": "json_schema",
728                    "json_schema": Value::Object(json_schema.clone()),
729                }));
730            }
731
732            let mut json_schema = Map::new();
733            for field in ["name", "description", "schema", "strict"] {
734                if let Some(value) = format.get(field) {
735                    json_schema.insert(field.to_string(), value.clone());
736                }
737            }
738            (!json_schema.is_empty()).then_some(json!({
739                "type": "json_schema",
740                "json_schema": Value::Object(json_schema),
741            }))
742        }
743        Some("json_object") => Some(json!({"type": "json_object"})),
744        Some("text") => Some(json!({"type": "text"})),
745        _ => None,
746    }
747}
748
749// Encodes normalized messages into the Responses `input` shape.
750fn encode_responses_input(
751    messages: &[Message],
752    diagnostics: &mut Vec<TranslationDiagnostic>,
753    policy: &TranslationPolicy,
754) -> Result<Value> {
755    if messages.len() == 1
756        && matches!(messages[0].role, Role::User)
757        && messages[0].content.len() == 1
758        && matches!(messages[0].content[0], ContentBlock::Text { .. })
759    {
760        if let ContentBlock::Text { text } = &messages[0].content[0] {
761            return Ok(Value::String(text.clone()));
762        }
763    }
764    let mut encoded = Vec::new();
765    for message in messages {
766        if message.content.iter().any(|block| {
767            matches!(
768                block,
769                ContentBlock::ToolCall(_) | ContentBlock::ToolResult(_)
770            )
771        }) {
772            encoded.extend(
773                message
774                    .content
775                    .iter()
776                    .filter_map(encode_responses_special_input),
777            );
778            continue;
779        }
780        let mut visible_content = Vec::new();
781        let mut emitted_special = false;
782        for block in &message.content {
783            if let Some(item) = encode_responses_special_input(block) {
784                encoded.push(item);
785                emitted_special = true;
786            } else {
787                visible_content.push(block.clone());
788            }
789        }
790        if !visible_content.is_empty() || !emitted_special {
791            let content = encode_responses_content(&visible_content, diagnostics, policy)?;
792            encoded.push(json!({
793                "type": "message",
794                "role": role_to_responses(message.role),
795                "content": content,
796            }));
797        }
798    }
799    Ok(Value::Array(encoded))
800}
801
802// Encodes IR blocks that Responses represents as top-level input items.
803fn encode_responses_special_input(block: &ContentBlock) -> Option<Value> {
804    match block {
805        ContentBlock::Reasoning { text, .. } => Some(json!({
806            "type": "reasoning",
807            "content": [{"type": "reasoning_text", "text": text}],
808            "summary": [],
809        })),
810        ContentBlock::ToolCall(call) => Some(json!({
811            "type": "function_call",
812            "call_id": call.id,
813            "name": call.name,
814            "arguments": call.arguments,
815        })),
816        ContentBlock::ToolResult(result) => Some(json!({
817            "type": "function_call_output",
818            "call_id": result.tool_call_id,
819            "output": text_from_blocks(&result.content, " "),
820        })),
821        _ => None,
822    }
823}
824
825// Maps normalized roles back to Responses role strings.
826fn role_to_responses(role: Role) -> &'static str {
827    match role {
828        Role::Assistant => "assistant",
829        Role::System => "system",
830        Role::Developer => "developer",
831        Role::User | Role::Tool => "user",
832    }
833}
834
835// Encodes normalized content into Responses message content.
836fn encode_responses_content(
837    content: &[ContentBlock],
838    diagnostics: &mut Vec<TranslationDiagnostic>,
839    policy: &TranslationPolicy,
840) -> Result<Value> {
841    let has_non_text = content.iter().any(|block| {
842        !matches!(
843            block,
844            ContentBlock::Text { .. }
845                | ContentBlock::Refusal { .. }
846                | ContentBlock::Reasoning { .. }
847        )
848    });
849    if !has_non_text {
850        return Ok(Value::String(text_from_blocks(content, "\n")));
851    }
852    let mut blocks = Vec::new();
853    for block in content {
854        match block {
855            ContentBlock::Text { text } => blocks.push(json!({"type": "input_text", "text": text})),
856            ContentBlock::Refusal { text } => {
857                blocks.push(json!({"type": "refusal", "refusal": text}));
858            }
859            ContentBlock::Image { source } => {
860                blocks.push(json!({"type": "input_image", "image_url": source}));
861            }
862            ContentBlock::Audio { source } => blocks.push(match source {
863                MediaSource::Raw(raw) => json!({"type": "input_text", "text": json_string(raw)}),
864                MediaSource::Url { url, media_type } => json!({
865                    "type": "input_audio",
866                    "audio_url": url,
867                    "media_type": media_type,
868                }),
869                MediaSource::Base64 { media_type, data } => json!({
870                    "type": "input_audio",
871                    "audio": {"media_type": media_type, "data": data},
872                }),
873            }),
874            ContentBlock::Video { source } => blocks.push(match source {
875                MediaSource::Raw(raw) => json!({"type": "input_text", "text": json_string(raw)}),
876                MediaSource::Url { url, media_type } => json!({
877                    "type": "input_video",
878                    "video_url": url,
879                    "media_type": media_type,
880                }),
881                MediaSource::Base64 { media_type, data } => json!({
882                    "type": "input_video",
883                    "video": {"media_type": media_type, "data": data},
884                }),
885            }),
886            ContentBlock::File { source } => {
887                blocks.push(json!({"type": "input_file", "file": source}));
888            }
889            ContentBlock::Unknown { raw, .. } => {
890                push_lossy(
891                    diagnostics,
892                    policy,
893                    "unknown content block encoded as text for Responses",
894                )?;
895                blocks.push(json!({"type": "input_text", "text": json_string(raw)}));
896            }
897            ContentBlock::Reasoning { .. }
898            | ContentBlock::ToolCall(_)
899            | ContentBlock::ToolResult(_) => {}
900        }
901    }
902    Ok(Value::Array(blocks))
903}
904
905// Encodes normalized tool definitions into Responses tool JSON.
906fn encode_responses_tools(tools: &[ToolDefinition]) -> Value {
907    Value::Array(
908        tools
909            .iter()
910            .map(|tool| {
911                let mut item = json!({
912                    "type": "function",
913                    "name": tool.name,
914                    "description": tool.description.clone().unwrap_or_default(),
915                    "parameters": tool.parameters,
916                });
917                if let Some(strict) = tool.strict {
918                    item["strict"] = Value::Bool(strict);
919                }
920                item
921            })
922            .collect(),
923    )
924}
925
926// Encodes normalized tool choice into Responses JSON.
927fn encode_responses_tool_choice(choice: &ToolChoice) -> Option<Value> {
928    match choice {
929        ToolChoice::Auto => Some(Value::String("auto".to_string())),
930        ToolChoice::Required => Some(Value::String("required".to_string())),
931        ToolChoice::None => Some(Value::String("none".to_string())),
932        ToolChoice::Tool { name } => Some(json!({"type": "function", "name": name})),
933        ToolChoice::Raw(value) => Some(value.clone()),
934    }
935}
936
937// Decodes one Responses output item into a normalized response output.
938fn decode_responses_output_item(
939    item: &Value,
940    diagnostics: &mut Vec<TranslationDiagnostic>,
941    policy: &TranslationPolicy,
942) -> Result<Option<ResponseOutput>> {
943    let Some(item) = item.as_object() else {
944        push_lossy(
945            diagnostics,
946            policy,
947            "Responses output item is not an object",
948        )?;
949        return Ok(None);
950    };
951    match item.get("type").and_then(Value::as_str) {
952        Some("message") => Ok(Some(ResponseOutput {
953            role: role_from_responses(item.get("role").and_then(Value::as_str)),
954            content: decode_responses_content(item.get("content").unwrap_or(&Value::Null)),
955            stop_reason: Some(StopReason::EndTurn),
956        })),
957        Some("function_call") => Ok(Some(ResponseOutput {
958            role: Role::Assistant,
959            content: vec![ContentBlock::ToolCall(ToolCall {
960                id: item
961                    .get("call_id")
962                    .and_then(Value::as_str)
963                    .unwrap_or_default()
964                    .to_string(),
965                name: item
966                    .get("name")
967                    .and_then(Value::as_str)
968                    .unwrap_or_default()
969                    .to_string(),
970                arguments: item.get("arguments").cloned().unwrap_or_else(|| json!({})),
971            })],
972            stop_reason: Some(StopReason::ToolUse),
973        })),
974        Some("reasoning") => Ok(Some(ResponseOutput {
975            role: Role::Assistant,
976            content: decode_responses_reasoning_item(item),
977            stop_reason: None,
978        })),
979        _ => Ok(None),
980    }
981}
982
983// Encodes normalized response outputs into Responses output items.
984fn encode_responses_output(outputs: &[ResponseOutput]) -> Value {
985    Value::Array(
986        outputs
987            .iter()
988            .flat_map(|output| {
989                let has_tool_calls = output
990                    .content
991                    .iter()
992                    .any(|block| matches!(block, ContentBlock::ToolCall(_)));
993                let text = text_from_blocks(&output.content, "");
994                let reasoning = reasoning_text_from_blocks(&output.content, "\n");
995                let mut items = Vec::new();
996
997                if !reasoning.is_empty() {
998                    items.push(encode_responses_reasoning_output(&reasoning));
999                }
1000
1001                if !text.is_empty() || (!has_tool_calls && reasoning.is_empty()) {
1002                    items.push(json!({
1003                        "type": "message",
1004                        "id": "msg_switchyard",
1005                        "status": "completed",
1006                        "role": role_to_responses(output.role),
1007                        "content": [{
1008                            "type": "output_text",
1009                            "text": text,
1010                            "annotations": [],
1011                        }],
1012                    }));
1013                }
1014
1015                items.extend(output.content.iter().filter_map(|block| match block {
1016                    ContentBlock::ToolCall(call) => Some(json!({
1017                        "type": "function_call",
1018                        "call_id": call.id,
1019                        "name": call.name,
1020                        "arguments": json_string_python_style(&call.arguments),
1021                    })),
1022                    _ => None,
1023                }));
1024
1025                items
1026            })
1027            .collect(),
1028    )
1029}
1030
1031// Encodes private reasoning as a separate Responses output item.
1032fn encode_responses_reasoning_output(text: &str) -> Value {
1033    json!({
1034        "type": "reasoning",
1035        "id": "rs_switchyard",
1036        "status": "completed",
1037        "content": [{
1038            "type": "reasoning_text",
1039            "text": text,
1040        }],
1041        "summary": [],
1042    })
1043}
1044
1045// Serializes JSON with Python-like spacing to match legacy converter behavior.
1046fn json_string_python_style(value: &Value) -> String {
1047    match value {
1048        Value::Null => "null".to_string(),
1049        Value::Bool(value) => value.to_string(),
1050        Value::Number(value) => value.to_string(),
1051        Value::String(value) => serde_json::to_string(value).unwrap_or_else(|_| value.clone()),
1052        Value::Array(items) => {
1053            let body = items
1054                .iter()
1055                .map(json_string_python_style)
1056                .collect::<Vec<_>>()
1057                .join(", ");
1058            format!("[{body}]")
1059        }
1060        Value::Object(object) => {
1061            let body = object
1062                .iter()
1063                .map(|(key, value)| {
1064                    let key = serde_json::to_string(key).unwrap_or_else(|_| format!("\"{key}\""));
1065                    format!("{key}: {}", json_string_python_style(value))
1066                })
1067                .collect::<Vec<_>>()
1068                .join(", ");
1069            format!("{{{body}}}")
1070        }
1071    }
1072}
1073
1074// Normalizes Responses usage fields.
1075fn decode_responses_usage(value: Option<&Value>) -> Usage {
1076    let Some(value) = value.and_then(Value::as_object) else {
1077        return Usage::default();
1078    };
1079    let input_tokens = value
1080        .get("input_tokens")
1081        .or_else(|| value.get("prompt_tokens"))
1082        .and_then(Value::as_u64);
1083    let output_tokens = value
1084        .get("output_tokens")
1085        .or_else(|| value.get("completion_tokens"))
1086        .and_then(Value::as_u64);
1087    Usage {
1088        input_tokens,
1089        output_tokens,
1090        total_tokens: value
1091            .get("total_tokens")
1092            .and_then(Value::as_u64)
1093            .or_else(|| {
1094                input_tokens
1095                    .zip(output_tokens)
1096                    .map(|(input, output)| input + output)
1097            }),
1098        reasoning_tokens: value
1099            .get("output_tokens_details")
1100            .and_then(|details| details.get("reasoning_tokens"))
1101            .or_else(|| {
1102                value
1103                    .get("completion_tokens_details")
1104                    .and_then(|details| details.get("reasoning_tokens"))
1105            })
1106            .and_then(Value::as_u64),
1107    }
1108}
1109
1110// Encodes normalized usage into Responses usage JSON.
1111fn encode_responses_usage(usage: &Usage) -> Value {
1112    let mut value = json!({
1113        "input_tokens": usage.input_tokens.unwrap_or(0),
1114        "output_tokens": usage.output_tokens.unwrap_or(0),
1115        "total_tokens": usage
1116            .total_tokens
1117            .or_else(|| Some(usage.input_tokens.unwrap_or(0) + usage.output_tokens.unwrap_or(0)))
1118            .unwrap_or(0),
1119    });
1120    if let Some(reasoning_tokens) = usage.reasoning_tokens {
1121        value["output_tokens_details"] = json!({
1122            "reasoning_tokens": reasoning_tokens,
1123        });
1124    }
1125    value
1126}