Skip to main content

switchyard_translation/codecs/openai_chat/
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 Chat Completions request and response JSON.
5
6use serde_json::{Map, Value, json};
7
8use crate::codecs::common::{
9    first_nonempty_string, is_known_role_name, provider_extensions, reasoning_text_from_blocks,
10    reasoning_text_from_details, text_from_blocks,
11};
12use crate::codecs::openai_media::{
13    ImagePayload, file_payload, file_source_text, image_payload, image_source_text,
14};
15use crate::codecs::structured_output::{decode_openai_schema_enforcement, encode_openai_format};
16use crate::codecs::{
17    DecodedRequest, DecodedResponse, EncodedRequest, EncodedResponse, FormatCodec,
18};
19use crate::diagnostic::TranslationDiagnostic;
20use crate::error::{Result, TranslationError};
21use crate::format::{FormatId, WireFormat};
22use crate::llm::{
23    AggLlmResponse, ContentBlock, FileSource, ImageSource, InstructionBlock, LlmRequest,
24    MediaSource, Message, OutputParams, ProviderExtensions, ReasoningParams, ResponseOutput, Role,
25    SamplingParams, StopReason, Tool, ToolCall, ToolChoice, ToolDefinition, ToolResult, Usage,
26};
27use crate::policy::{DeterministicIdPolicy, TranslationPolicy};
28use crate::util::{
29    capture_request_preservation, capture_response_preservation, embed_preservation,
30    exact_preserved_request, exact_preserved_response, json_string, object, push_lossy,
31    reject_responses_builtin_tool_item, stable_id, string_value, validate_request_capabilities,
32};
33
34/// Format codec for OpenAI Chat Completions payloads.
35pub struct OpenAiChatCodec;
36
37impl FormatCodec for OpenAiChatCodec {
38    fn format(&self) -> FormatId {
39        WireFormat::OpenAiChat.into()
40    }
41
42    fn decode_request(&self, body: &Value, policy: &TranslationPolicy) -> Result<DecodedRequest> {
43        let body = object(body, "$")?;
44        let mut diagnostics = Vec::new();
45        let mut request = LlmRequest {
46            model: body
47                .get("model")
48                .and_then(Value::as_str)
49                .filter(|model| !model.is_empty())
50                .map(ToOwned::to_owned),
51            stream: body.get("stream").and_then(Value::as_bool).unwrap_or(false),
52            sampling: SamplingParams {
53                temperature: body.get("temperature").and_then(Value::as_f64),
54                top_p: body.get("top_p").and_then(Value::as_f64),
55                top_k: None,
56            },
57            output: OutputParams {
58                max_output_tokens: body
59                    .get("max_completion_tokens")
60                    .or_else(|| body.get("max_tokens"))
61                    .and_then(Value::as_u64),
62                response_format: body.get("response_format").cloned(),
63                is_schema_enforced: decode_openai_schema_enforcement(body.get("response_format")),
64            },
65            reasoning: ReasoningParams {
66                effort: body
67                    .get("reasoning_effort")
68                    .and_then(Value::as_str)
69                    .map(ToOwned::to_owned),
70                raw: None,
71            },
72            preservation: capture_request_preservation(
73                WireFormat::OpenAiChat,
74                &Value::Object(body.clone()),
75                policy,
76            ),
77            ..LlmRequest::default()
78        };
79
80        if let Some(messages) = body.get("messages") {
81            let messages = messages
82                .as_array()
83                .ok_or_else(|| TranslationError::InvalidType {
84                    path: "$.messages".to_string(),
85                    expected: "array",
86                })?;
87            let mut generated_id = 0;
88            for (index, message) in messages.iter().enumerate() {
89                let Some(message) = message.as_object() else {
90                    push_lossy(
91                        &mut diagnostics,
92                        policy,
93                        format!("OpenAI message at index {index} is not an object"),
94                    )?;
95                    continue;
96                };
97                let role = role_from_openai(
98                    message.get("role").and_then(Value::as_str),
99                    &format!("$.messages[{index}].role"),
100                )?;
101                let mut content = decode_openai_content(
102                    message.get("content").unwrap_or(&Value::Null),
103                    WireFormat::OpenAiChat,
104                    &mut diagnostics,
105                    policy,
106                    format!("$.messages[{index}].content"),
107                )?;
108                prepend_openai_reasoning_blocks(&mut content, message);
109                if role == Role::Assistant
110                    && let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array)
111                {
112                    if is_empty_text_only(&content) {
113                        content.clear();
114                    }
115                    for tool_call in tool_calls {
116                        generated_id += 1;
117                        if let Some(call) =
118                            decode_openai_tool_call(tool_call, generated_id, policy)?
119                        {
120                            content.push(ContentBlock::ToolCall(call));
121                        }
122                    }
123                }
124                if role == Role::Tool {
125                    let text = content
126                        .iter()
127                        .filter_map(|block| match block {
128                            ContentBlock::Text { text } => Some(text.as_str()),
129                            _ => None,
130                        })
131                        .collect::<Vec<_>>()
132                        .join("\n");
133                    let tool_call_id = message
134                        .get("tool_call_id")
135                        .and_then(Value::as_str)
136                        .unwrap_or_default()
137                        .to_string();
138                    request.messages.push(Message {
139                        role: Role::User,
140                        content: vec![ContentBlock::ToolResult(ToolResult {
141                            tool_call_id,
142                            content: vec![ContentBlock::Text { text }],
143                            is_error: message.get("is_error").and_then(Value::as_bool),
144                        })],
145                    });
146                    continue;
147                }
148                match role {
149                    Role::System | Role::Developer => {
150                        request
151                            .instructions
152                            .push(InstructionBlock { role, content });
153                    }
154                    Role::User | Role::Assistant | Role::Tool => {
155                        request.messages.push(Message { role, content });
156                    }
157                }
158            }
159        }
160
161        request.tools = decode_openai_tools(body.get("tools"), &mut diagnostics, policy)?
162            .into_iter()
163            .map(Tool::from)
164            .collect();
165        request.tool_choice = body.get("tool_choice").map(decode_openai_tool_choice);
166        request.extensions.fields = provider_extensions(
167            body,
168            &[
169                "model",
170                "messages",
171                "stream",
172                "temperature",
173                "top_p",
174                "max_completion_tokens",
175                "max_tokens",
176                "response_format",
177                "reasoning_effort",
178                "tools",
179                "tool_choice",
180            ],
181        );
182
183        Ok(DecodedRequest {
184            request,
185            diagnostics,
186        })
187    }
188
189    fn encode_request(
190        &self,
191        request: &LlmRequest,
192        policy: &TranslationPolicy,
193    ) -> Result<EncodedRequest> {
194        if let Some(body) =
195            exact_preserved_request(&request.preservation, WireFormat::OpenAiChat, policy)
196        {
197            return Ok(EncodedRequest {
198                body,
199                diagnostics: Vec::new(),
200            });
201        }
202        let mut diagnostics = Vec::new();
203        validate_request_capabilities(request, &mut diagnostics, policy)?;
204        let allowed = match &request.tool_choice {
205            Some(ToolChoice::Raw(choice))
206                if choice.get("type").and_then(Value::as_str) == Some("allowed_tools")
207                    && choice.get("allowed_tools").is_some() =>
208            {
209                None
210            }
211            _ => crate::codecs::common::allowed_function_tools(request)?,
212        };
213        let (tools, tool_choice) = allowed.as_ref().map_or(
214            (request.tools.as_slice(), request.tool_choice.as_ref()),
215            |(tools, choice)| (tools.as_slice(), Some(choice)),
216        );
217        let mut body = Map::new();
218        if let Some(model) = &request.model {
219            body.insert("model".to_string(), Value::String(model.clone()));
220        }
221
222        let mut messages = Vec::new();
223        for instruction in &request.instructions {
224            let role = match instruction.role {
225                Role::Developer => "developer",
226                _ => "system",
227            };
228            messages.push(json!({
229                "role": role,
230                "content": text_from_blocks(&instruction.content, "\n\n"),
231            }));
232        }
233        for message in &request.messages {
234            messages.extend(encode_message_to_openai(message, &mut diagnostics, policy)?);
235        }
236        body.insert("messages".to_string(), Value::Array(messages));
237
238        if !tools.is_empty() {
239            body.insert("tools".to_string(), encode_openai_tools(tools)?);
240            if let Some(choice) = tool_choice {
241                body.insert("tool_choice".to_string(), encode_openai_tool_choice(choice));
242            }
243        }
244        if let Some(value) = request.output.max_output_tokens {
245            body.insert("max_completion_tokens".to_string(), json!(value));
246        }
247        if let Some(value) = request.sampling.temperature {
248            body.insert("temperature".to_string(), json!(value));
249        }
250        if let Some(value) = request.sampling.top_p {
251            body.insert("top_p".to_string(), json!(value));
252        }
253        if request.stream {
254            body.insert("stream".to_string(), Value::Bool(true));
255        }
256        if let Some(effort) = &request.reasoning.effort {
257            body.insert(
258                "reasoning_effort".to_string(),
259                Value::String(effort.clone()),
260            );
261        }
262        if let Some(format) = encode_openai_format(&request.output, &mut diagnostics, policy)? {
263            body.insert("response_format".to_string(), format);
264        }
265        copy_openai_chat_request_extensions(&mut body, &request.extensions.fields);
266
267        let body = embed_preservation(Value::Object(body), &request.preservation, policy);
268        Ok(EncodedRequest { body, diagnostics })
269    }
270
271    fn decode_response(
272        &self,
273        body: &Value,
274        _policy: &TranslationPolicy,
275    ) -> Result<DecodedResponse> {
276        let object = object(body, "$")?;
277        // `abort`, `error` and `repetition` mean the generation did not complete.
278        if let Some(reason @ ("abort" | "error" | "repetition")) = object
279            .get("choices")
280            .and_then(Value::as_array)
281            .and_then(|choices| choices.first())
282            .and_then(|choice| choice.get("finish_reason"))
283            .and_then(Value::as_str)
284        {
285            return Err(TranslationError::UpstreamFailure {
286                error: json!({ "message": format!("provider finished with finish_reason \"{reason}\"") }),
287            });
288        }
289        let mut response = AggLlmResponse {
290            id: object
291                .get("id")
292                .and_then(Value::as_str)
293                .map(ToOwned::to_owned),
294            model: object
295                .get("model")
296                .and_then(Value::as_str)
297                .map(ToOwned::to_owned),
298            outputs: Vec::new(),
299            usage: decode_openai_usage(object.get("usage")),
300            extensions: ProviderExtensions {
301                fields: provider_extensions(object, &["id", "model", "choices", "usage"]),
302            },
303            preservation: capture_response_preservation(
304                WireFormat::OpenAiChat,
305                &Value::Object(object.clone()),
306                _policy,
307            ),
308        };
309        if let Some(choice) = object
310            .get("choices")
311            .and_then(Value::as_array)
312            .and_then(|choices| choices.first())
313            .and_then(Value::as_object)
314        {
315            let message = choice
316                .get("message")
317                .and_then(Value::as_object)
318                .cloned()
319                .unwrap_or_default();
320            let refusal = message
321                .get("refusal")
322                .and_then(Value::as_str)
323                .filter(|text| !text.is_empty());
324            let mut content = match message.get("content") {
325                // decode_openai_content would turn absent response text into an empty text block.
326                None | Some(Value::Null) => Vec::new(),
327                Some(content) => decode_openai_content(
328                    content,
329                    WireFormat::OpenAiChat,
330                    &mut Vec::new(),
331                    &TranslationPolicy::default(),
332                    "$.choices[0].message.content",
333                )?,
334            };
335            prepend_openai_reasoning_blocks(&mut content, &message);
336            if let Some(text) = refusal {
337                content.push(ContentBlock::Refusal {
338                    text: text.to_string(),
339                });
340            }
341            if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) {
342                for (index, tool_call) in tool_calls.iter().enumerate() {
343                    if let Some(call) = decode_openai_tool_call(
344                        tool_call,
345                        index + 1,
346                        &TranslationPolicy::default(),
347                    )? {
348                        content.push(ContentBlock::ToolCall(call));
349                    }
350                }
351            }
352            let finish_reason = choice.get("finish_reason").and_then(Value::as_str);
353            let stop_reason = if refusal.is_some() && matches!(finish_reason, Some("stop") | None) {
354                StopReason::ContentFilter
355            } else {
356                map_openai_finish_reason(finish_reason)
357            };
358            response.outputs.push(ResponseOutput {
359                url_citations: message
360                    .get("annotations")
361                    .and_then(Value::as_array)
362                    .into_iter()
363                    .flatten()
364                    .filter(|annotation| annotation["type"] == "url_citation")
365                    .filter_map(|annotation| {
366                        serde_json::from_value(annotation["url_citation"].clone()).ok()
367                    })
368                    .collect(),
369                role: Role::Assistant,
370                content,
371                stop_reason: Some(stop_reason),
372            });
373        }
374
375        Ok(DecodedResponse {
376            response,
377            diagnostics: Vec::new(),
378        })
379    }
380
381    fn encode_response(
382        &self,
383        response: &AggLlmResponse,
384        _policy: &TranslationPolicy,
385    ) -> Result<EncodedResponse> {
386        super::super::responses::validate_response_output(response, WireFormat::OpenAiChat)?;
387        if let Some(body) =
388            exact_preserved_response(&response.preservation, WireFormat::OpenAiChat, _policy)
389        {
390            return Ok(EncodedResponse {
391                body,
392                diagnostics: Vec::new(),
393            });
394        }
395        let output = response.first_output();
396        let content = output
397            .map(|output| text_from_blocks(&output.content, ""))
398            .unwrap_or_default();
399        let tool_calls = output
400            .map(|output| {
401                output
402                    .content
403                    .iter()
404                    .filter_map(|block| match block {
405                        ContentBlock::ToolCall(call) => Some(json!({
406                            "id": call.id,
407                            "type": "function",
408                            "function": {
409                                "name": call.name,
410                                "arguments": json_string(&call.arguments),
411                            },
412                        })),
413                        _ => None,
414                    })
415                    .collect::<Vec<_>>()
416            })
417            .unwrap_or_default();
418        let mut message = json!({
419            "role": "assistant",
420            "content": if content.is_empty() && !tool_calls.is_empty() {
421                Value::Null
422            } else {
423                Value::String(content)
424            },
425        });
426        if let Some(output) = output.filter(|output| !output.url_citations.is_empty()) {
427            message["annotations"] = Value::Array(
428                output
429                    .url_citations
430                    .iter()
431                    .map(|citation| json!({"type": "url_citation", "url_citation": citation}))
432                    .collect(),
433            );
434        }
435        if let Some(reasoning) = output
436            .map(|output| reasoning_text_from_blocks(&output.content, "\n"))
437            .filter(|reasoning| !reasoning.is_empty())
438        {
439            message["reasoning_content"] = Value::String(reasoning);
440        }
441        if let Some(details) = output
442            .map(|output| reasoning_details_from_blocks(&output.content))
443            .filter(|details| !details.is_empty())
444        {
445            message["reasoning_details"] = Value::Array(details);
446        }
447        if !tool_calls.is_empty() {
448            message["tool_calls"] = Value::Array(tool_calls);
449        }
450
451        let body = json!({
452            "id": response.id.clone().unwrap_or_else(|| "chatcmpl_switchyard".to_string()),
453            "object": "chat.completion",
454            "created": 0,
455            "model": response.model.clone().unwrap_or_else(|| "unknown".to_string()),
456            "choices": [{
457                "index": 0,
458                "message": message,
459                "finish_reason": output
460                    .and_then(|output| output.stop_reason)
461                    .map(openai_finish_reason)
462                    .unwrap_or("stop"),
463            }],
464            "usage": encode_openai_usage(&response.usage),
465        });
466        Ok(EncodedResponse {
467            body: embed_preservation(body, &response.preservation, _policy),
468            diagnostics: Vec::new(),
469        })
470    }
471}
472
473// Pulls OpenAI-compatible reasoning fields into private reasoning IR blocks.
474fn prepend_openai_reasoning_blocks(content: &mut Vec<ContentBlock>, object: &Map<String, Value>) {
475    if let Some(details) = object
476        .get("reasoning_details")
477        .and_then(Value::as_array)
478        .filter(|details| !details.is_empty())
479    {
480        let text = reasoning_text_from_details(details)
481            .or_else(|| {
482                first_nonempty_string(object, &["reasoning_content", "reasoning"])
483                    .map(ToOwned::to_owned)
484            })
485            .unwrap_or_default();
486        let signature = details.iter().find_map(|detail| {
487            detail
488                .get("signature")
489                .and_then(Value::as_str)
490                .filter(|signature| !signature.is_empty())
491                .map(ToOwned::to_owned)
492        });
493        content.insert(
494            0,
495            ContentBlock::Reasoning {
496                text,
497                signature,
498                details: details.clone(),
499            },
500        );
501        return;
502    }
503
504    if let Some(text) = first_nonempty_string(object, &["reasoning_content", "reasoning"]) {
505        content.insert(
506            0,
507            ContentBlock::Reasoning {
508                text: text.to_string(),
509                signature: None,
510                details: Vec::new(),
511            },
512        );
513    }
514}
515
516// Returns structured reasoning details in their original order.
517fn reasoning_details_from_blocks(content: &[ContentBlock]) -> Vec<Value> {
518    content
519        .iter()
520        .filter_map(|block| match block {
521            ContentBlock::Reasoning { details, .. } => Some(details.as_slice()),
522            _ => None,
523        })
524        .flatten()
525        .cloned()
526        .collect()
527}
528
529/// Decodes OpenAI role strings into normalized roles.
530///
531/// Unknown role strings are rejected with [`TranslationError::InvalidValue`] so
532/// the router returns the same `invalid_value` error the provider would, rather
533/// than silently coercing an invalid role to `user`. A missing role and
534/// known-but-unmapped roles (e.g. the legacy `function` role) keep their
535/// historical mapping to `user`. `path` points at the offending field.
536pub(crate) fn role_from_openai(role: Option<&str>, path: &str) -> Result<Role> {
537    match role {
538        Some("system") => Ok(Role::System),
539        Some("developer") => Ok(Role::Developer),
540        Some("assistant") => Ok(Role::Assistant),
541        Some("tool") => Ok(Role::Tool),
542        None => Ok(Role::User),
543        Some(other) if is_known_role_name(other) => Ok(Role::User),
544        Some(other) => Err(TranslationError::unsupported_role(path, other)),
545    }
546}
547
548/// Decodes OpenAI-style content into normalized content blocks.
549pub(crate) fn decode_openai_content(
550    content: &Value,
551    provider: WireFormat,
552    diagnostics: &mut Vec<TranslationDiagnostic>,
553    policy: &TranslationPolicy,
554    path: impl Into<String>,
555) -> Result<Vec<ContentBlock>> {
556    let path = path.into();
557    match content {
558        Value::Null => Ok(vec![ContentBlock::Text {
559            text: String::new(),
560        }]),
561        Value::String(text) => Ok(vec![ContentBlock::Text { text: text.clone() }]),
562        Value::Array(blocks) => {
563            let mut content = Vec::new();
564            for (index, block) in blocks.iter().enumerate() {
565                let Some(block) = block.as_object() else {
566                    push_lossy(
567                        diagnostics,
568                        policy,
569                        format!("content block at {path}[{index}] is not an object"),
570                    )?;
571                    continue;
572                };
573                match block.get("type").and_then(Value::as_str) {
574                    Some("text") | Some("input_text") | Some("output_text") => {
575                        content.push(ContentBlock::Text {
576                            text: block
577                                .get("text")
578                                .and_then(Value::as_str)
579                                .unwrap_or_default()
580                                .to_string(),
581                        });
582                    }
583                    Some("refusal") => {
584                        content.push(ContentBlock::Refusal {
585                            text: block
586                                .get("refusal")
587                                .and_then(Value::as_str)
588                                .unwrap_or_default()
589                                .to_string(),
590                        });
591                    }
592                    Some("image_url") | Some("input_image") => {
593                        if let Some(source) = decode_image_source(block) {
594                            content.push(ContentBlock::Image { source });
595                        }
596                    }
597                    Some("input_audio") => content.push(ContentBlock::Audio {
598                        source: MediaSource::Raw(Value::Object(block.clone())),
599                    }),
600                    Some("file") | Some("input_file") => {
601                        content.push(ContentBlock::File {
602                            source: decode_file_source(block),
603                        });
604                    }
605                    _ => content.push(ContentBlock::Unknown {
606                        provider: provider.into(),
607                        raw: Value::Object(block.clone()),
608                    }),
609                }
610            }
611            if content.is_empty() {
612                Ok(vec![ContentBlock::Text {
613                    text: String::new(),
614                }])
615            } else {
616                Ok(content)
617            }
618        }
619        other => Ok(vec![ContentBlock::Text {
620            text: string_value(other).unwrap_or_default(),
621        }]),
622    }
623}
624
625/// Decodes OpenAI image block shapes into normalized image sources.
626pub(crate) fn decode_image_source(block: &Map<String, Value>) -> Option<ImageSource> {
627    if let Some(image_url) = block.get("image_url") {
628        if let Some(url) = image_url.as_str() {
629            return Some(ImageSource::Url {
630                url: url.to_string(),
631                detail: block
632                    .get("detail")
633                    .and_then(Value::as_str)
634                    .map(ToOwned::to_owned),
635            });
636        }
637        if let Some(payload) = image_url.as_object() {
638            return payload
639                .get("url")
640                .and_then(Value::as_str)
641                .map(|url| ImageSource::Url {
642                    url: url.to_string(),
643                    detail: payload
644                        .get("detail")
645                        .or_else(|| block.get("detail"))
646                        .and_then(Value::as_str)
647                        .map(ToOwned::to_owned),
648                });
649        }
650    }
651    if let Some(image_url) = block.get("image_url").and_then(Value::as_str) {
652        return Some(ImageSource::Url {
653            url: image_url.to_string(),
654            detail: block
655                .get("detail")
656                .and_then(Value::as_str)
657                .map(ToOwned::to_owned),
658        });
659    }
660    Some(ImageSource::Raw(Value::Object(block.clone())))
661}
662
663/// Decodes OpenAI file block shapes into normalized file sources.
664///
665/// Accepts both the Chat shape, which nests the payload under a `file` object,
666/// and the Responses shape, which carries `file_id`/`file_data`/`filename`
667/// directly on the block. Returns `FileSource::Raw` when neither shape carries a
668/// field this can normalize.
669pub(crate) fn decode_file_source(block: &Map<String, Value>) -> FileSource {
670    if let Some(file) = block.get("file").and_then(Value::as_object) {
671        if let Some(file_id) = file.get("file_id").and_then(Value::as_str) {
672            return FileSource::FileId(file_id.to_string());
673        }
674        if let Some(file_data) = file.get("file_data").and_then(Value::as_str) {
675            return FileSource::FileData {
676                data: file_data.to_string(),
677                filename: file
678                    .get("filename")
679                    .and_then(Value::as_str)
680                    .map(ToOwned::to_owned),
681            };
682        }
683        return FileSource::Raw(Value::Object(file.clone()));
684    }
685    // A Chat block never reaches here with these keys, having matched the nested
686    // branch above.
687    if let Some(file_id) = block.get("file_id").and_then(Value::as_str) {
688        return FileSource::FileId(file_id.to_string());
689    }
690    if let Some(file_data) = block.get("file_data").and_then(Value::as_str) {
691        return FileSource::FileData {
692            data: file_data.to_string(),
693            filename: block
694                .get("filename")
695                .and_then(Value::as_str)
696                .map(ToOwned::to_owned),
697        };
698    }
699    FileSource::Raw(Value::Object(block.clone()))
700}
701
702/// Decodes one OpenAI tool call into a normalized tool call.
703pub(crate) fn decode_openai_tool_call(
704    tool_call: &Value,
705    generated_counter: usize,
706    policy: &TranslationPolicy,
707) -> Result<Option<ToolCall>> {
708    let Some(tool_call) = tool_call.as_object() else {
709        return Ok(None);
710    };
711    let function = tool_call
712        .get("function")
713        .and_then(Value::as_object)
714        .cloned()
715        .unwrap_or_default();
716    let id = tool_call
717        .get("id")
718        .and_then(Value::as_str)
719        .filter(|id| !id.is_empty())
720        .map(ToOwned::to_owned)
721        .unwrap_or_else(|| match &policy.deterministic_ids {
722            DeterministicIdPolicy::GenerateStable { prefix } => {
723                stable_id(prefix, generated_counter)
724            }
725            DeterministicIdPolicy::Preserve => String::new(),
726        });
727    // A custom (freeform) call has `custom.name` and a raw `custom.input` string. Carry the
728    // input as the single `input` argument, the same way Responses custom calls are carried.
729    let custom = tool_call.get("custom").and_then(Value::as_object);
730    let arguments = match custom {
731        Some(custom) => json!({
732            crate::codex_custom_tools::INPUT_ARGUMENT:
733                custom.get("input").and_then(Value::as_str).unwrap_or_default()
734        }),
735        None => function
736            .get("arguments")
737            .map(parse_arguments)
738            .unwrap_or_else(|| json!({})),
739    };
740    let name = custom
741        .unwrap_or(&function)
742        .get("name")
743        .and_then(Value::as_str)
744        .unwrap_or_default()
745        .to_string();
746    Ok(Some(ToolCall {
747        id,
748        name,
749        arguments,
750    }))
751}
752
753/// Parses stringified tool-call arguments when possible.
754pub(crate) fn parse_arguments(value: &Value) -> Value {
755    match value {
756        Value::String(text) => serde_json::from_str(text).unwrap_or_else(|_| json!({"raw": text})),
757        other => other.clone(),
758    }
759}
760
761/// Decodes OpenAI tool definitions into normalized tool definitions.
762pub(crate) fn decode_openai_tools(
763    tools: Option<&Value>,
764    _diagnostics: &mut Vec<TranslationDiagnostic>,
765    _policy: &TranslationPolicy,
766) -> Result<Vec<ToolDefinition>> {
767    let Some(tools) = tools.and_then(Value::as_array) else {
768        return Ok(Vec::new());
769    };
770    let mut definitions = Vec::new();
771    for tool in tools {
772        let Some(tool) = tool.as_object() else {
773            continue;
774        };
775        let function = tool
776            .get("function")
777            .and_then(Value::as_object)
778            .cloned()
779            .unwrap_or_else(|| tool.clone());
780        let name = function
781            .get("name")
782            .and_then(Value::as_str)
783            .unwrap_or_default()
784            .to_string();
785        if name.is_empty() {
786            continue;
787        }
788        definitions.push(ToolDefinition {
789            name,
790            description: function
791                .get("description")
792                .and_then(Value::as_str)
793                .map(ToOwned::to_owned),
794            parameters: function
795                .get("parameters")
796                .cloned()
797                .unwrap_or_else(|| json!({})),
798            strict: function.get("strict").and_then(Value::as_bool),
799        });
800    }
801    Ok(definitions)
802}
803
804/// Decodes OpenAI tool-choice values into normalized policy.
805pub(crate) fn decode_openai_tool_choice(value: &Value) -> ToolChoice {
806    match value {
807        Value::String(text) if text == "auto" => ToolChoice::Auto,
808        Value::String(text) if text == "required" => ToolChoice::Required,
809        Value::String(text) if text == "none" => ToolChoice::None,
810        Value::Object(object) => object
811            .get("function")
812            .and_then(Value::as_object)
813            .and_then(|function| function.get("name"))
814            .and_then(Value::as_str)
815            .map(|name| ToolChoice::Tool {
816                name: name.to_string(),
817            })
818            .unwrap_or_else(|| ToolChoice::Raw(value.clone())),
819        _ => ToolChoice::Raw(value.clone()),
820    }
821}
822
823/// Encodes one normalized message into one or more OpenAI messages.
824pub(crate) fn encode_message_to_openai(
825    message: &Message,
826    diagnostics: &mut Vec<TranslationDiagnostic>,
827    policy: &TranslationPolicy,
828) -> Result<Vec<Value>> {
829    if message_has_tool_results(message) {
830        return encode_message_with_tool_results_to_openai(message, diagnostics, policy);
831    }
832
833    Ok(vec![encode_message_without_tool_results_to_openai(
834        message,
835        diagnostics,
836        policy,
837    )?])
838}
839
840// Splits mixed content so OpenAI tool-result messages stay separate.
841fn encode_message_with_tool_results_to_openai(
842    message: &Message,
843    diagnostics: &mut Vec<TranslationDiagnostic>,
844    policy: &TranslationPolicy,
845) -> Result<Vec<Value>> {
846    let mut out = Vec::new();
847    let mut pending_content = Vec::new();
848
849    for block in &message.content {
850        if let ContentBlock::ToolResult(result) = block {
851            out.push(json!({
852                "role": "tool",
853                "tool_call_id": result.tool_call_id,
854                "content": text_from_blocks(&result.content, " "),
855            }));
856            let non_text = result
857                .content
858                .iter()
859                .filter(|block| {
860                    !matches!(
861                        block,
862                        ContentBlock::Text { .. } | ContentBlock::Refusal { .. }
863                    )
864                })
865                .cloned()
866                .collect::<Vec<_>>();
867            if !non_text.is_empty() {
868                push_lossy(
869                    diagnostics,
870                    policy,
871                    "OpenAI Chat tool messages only support text; non-text tool-result content was moved to a user message",
872                )?;
873                pending_content.extend(non_text);
874            }
875        } else {
876            pending_content.push(block.clone());
877        }
878    }
879
880    push_pending_openai_message(
881        &mut out,
882        message.role,
883        &mut pending_content,
884        diagnostics,
885        policy,
886    )?;
887    Ok(out)
888}
889
890// Flushes accumulated non-tool-result content as a normal OpenAI message.
891fn push_pending_openai_message(
892    out: &mut Vec<Value>,
893    role: Role,
894    pending_content: &mut Vec<ContentBlock>,
895    diagnostics: &mut Vec<TranslationDiagnostic>,
896    policy: &TranslationPolicy,
897) -> Result<()> {
898    if pending_content.is_empty() {
899        return Ok(());
900    }
901
902    let message = Message {
903        role,
904        content: std::mem::take(pending_content),
905    };
906    out.push(encode_message_without_tool_results_to_openai(
907        &message,
908        diagnostics,
909        policy,
910    )?);
911    Ok(())
912}
913
914// Encodes a normalized message that has no tool-result blocks.
915fn encode_message_without_tool_results_to_openai(
916    message: &Message,
917    diagnostics: &mut Vec<TranslationDiagnostic>,
918    policy: &TranslationPolicy,
919) -> Result<Value> {
920    let role = match message.role {
921        Role::Assistant => "assistant",
922        Role::Tool => "tool",
923        _ => "user",
924    };
925    let tool_calls = message
926        .content
927        .iter()
928        .filter_map(|block| match block {
929            ContentBlock::ToolCall(call) => Some(json!({
930                "id": call.id,
931                "type": "function",
932                "function": {
933                    "name": call.name,
934                    "arguments": json_string(&call.arguments),
935                },
936            })),
937            _ => None,
938        })
939        .collect::<Vec<_>>();
940    let content_blocks = message
941        .content
942        .iter()
943        .filter(|block| {
944            !matches!(
945                block,
946                ContentBlock::ToolCall(_) | ContentBlock::Reasoning { .. }
947            )
948        })
949        .cloned()
950        .collect::<Vec<_>>();
951    let mut message_json = json!({
952        "role": role,
953        "content": encode_openai_content(&content_blocks, message.role, diagnostics, policy)?,
954    });
955    encode_openai_message_reasoning(&mut message_json, &message.content);
956    if !tool_calls.is_empty() {
957        message_json["tool_calls"] = Value::Array(tool_calls);
958        if message_json["content"] == Value::String(String::new()) {
959            message_json["content"] = Value::Null;
960        }
961    }
962    Ok(message_json)
963}
964
965// Adds either plaintext reasoning or structured details to an OpenAI Chat message.
966fn encode_openai_message_reasoning(message: &mut Value, content: &[ContentBlock]) {
967    let details = reasoning_details_from_blocks(content);
968    if details.is_empty() {
969        encode_openai_message_plaintext_reasoning(message, content);
970    } else {
971        encode_openai_message_structured_reasoning(message, content, details);
972    }
973}
974
975// Adds reasoning blocks that have no structured provider representation.
976fn encode_openai_message_plaintext_reasoning(message: &mut Value, content: &[ContentBlock]) {
977    let reasoning = content
978        .iter()
979        .filter_map(|block| match block {
980            ContentBlock::Reasoning {
981                text,
982                signature: None,
983                ..
984            } => Some(text.as_str()),
985            _ => None,
986        })
987        .collect::<Vec<_>>()
988        .join("\n");
989    if !reasoning.is_empty() {
990        message["reasoning"] = Value::String(reasoning);
991    }
992}
993
994// Adds exact provider details and text that cannot be recovered from those details.
995fn encode_openai_message_structured_reasoning(
996    message: &mut Value,
997    content: &[ContentBlock],
998    details: Vec<Value>,
999) {
1000    message["reasoning_details"] = Value::Array(details);
1001    let fallback = content
1002        .iter()
1003        .filter_map(|block| match block {
1004            ContentBlock::Reasoning { text, details, .. }
1005                if !details.is_empty()
1006                    && reasoning_text_from_details(details).as_deref() != Some(text.as_str())
1007                    && !text.is_empty() =>
1008            {
1009                Some(text.as_str())
1010            }
1011            _ => None,
1012        })
1013        .collect::<Vec<_>>()
1014        .join("\n");
1015    if !fallback.is_empty() {
1016        message["reasoning"] = Value::String(fallback);
1017    }
1018}
1019
1020// Checks whether any block in a message is a tool result.
1021fn message_has_tool_results(message: &Message) -> bool {
1022    message
1023        .content
1024        .iter()
1025        .any(|block| matches!(block, ContentBlock::ToolResult(_)))
1026}
1027
1028// Copies Chat-compatible extension fields preserved in the IR.
1029fn copy_openai_chat_request_extensions(
1030    body: &mut Map<String, Value>,
1031    extensions: &Map<String, Value>,
1032) {
1033    for field in [
1034        "metadata",
1035        "parallel_tool_calls",
1036        "prompt_cache_key",
1037        "prompt_cache_retention",
1038        "safety_identifier",
1039        "service_tier",
1040        "store",
1041        "stream_options",
1042        // `top_logprobs` is only valid alongside `logprobs`.
1043        "logprobs",
1044        "top_logprobs",
1045        "user",
1046    ] {
1047        if let Some(value) = extensions.get(field) {
1048            body.entry(field.to_string())
1049                .or_insert_with(|| value.clone());
1050        }
1051    }
1052    if let Some(stop_sequences) = extensions.get("stop_sequences") {
1053        body.entry("stop").or_insert_with(|| stop_sequences.clone());
1054    }
1055}
1056
1057// Detects placeholder empty text generated while decoding assistant tool calls.
1058fn is_empty_text_only(content: &[ContentBlock]) -> bool {
1059    matches!(content, [ContentBlock::Text { text }] if text.is_empty())
1060}
1061
1062/// Encodes normalized content into OpenAI Chat content JSON.
1063pub(crate) fn encode_openai_content(
1064    content: &[ContentBlock],
1065    role: Role,
1066    diagnostics: &mut Vec<TranslationDiagnostic>,
1067    policy: &TranslationPolicy,
1068) -> Result<Value> {
1069    if content
1070        .iter()
1071        .any(|block| matches!(block, ContentBlock::Mcp(_)))
1072    {
1073        return Err(TranslationError::LossyConversion(
1074            "MCP history encoding is not implemented".into(),
1075        ));
1076    }
1077    let has_non_text = content.iter().any(|block| {
1078        matches!(
1079            block,
1080            ContentBlock::Image { .. }
1081                | ContentBlock::Audio { .. }
1082                | ContentBlock::Video { .. }
1083                | ContentBlock::File { .. }
1084                | ContentBlock::Unknown { .. }
1085        )
1086    });
1087    if !has_non_text {
1088        return Ok(Value::String(text_from_blocks(content, "\n")));
1089    }
1090    if role != Role::User {
1091        push_lossy(
1092            diagnostics,
1093            policy,
1094            "OpenAI Chat only supports text content for non-user messages",
1095        )?;
1096        return Ok(Value::String(text_from_blocks(content, "\n")));
1097    }
1098    let mut blocks = Vec::new();
1099    for block in content {
1100        crate::codecs::openai_media::validate_media(block, WireFormat::OpenAiChat)?;
1101        match block {
1102            ContentBlock::Text { text } => blocks.push(json!({"type": "text", "text": text})),
1103            ContentBlock::Refusal { text } => blocks.push(json!({"type": "text", "text": text})),
1104            ContentBlock::Image { source } => match openai_image_part(source) {
1105                Some(part) => blocks.push(part),
1106                None => {
1107                    push_lossy(
1108                        diagnostics,
1109                        policy,
1110                        "OpenAI Chat codec could not map image content",
1111                    )?;
1112                    blocks.push(openai_text_part(&image_source_text(source)));
1113                }
1114            },
1115            ContentBlock::File { source } => match openai_file_part(source) {
1116                Some(part) => blocks.push(part),
1117                None => {
1118                    push_lossy(
1119                        diagnostics,
1120                        policy,
1121                        "OpenAI Chat codec could not map file content",
1122                    )?;
1123                    blocks.push(openai_text_part(&file_source_text(source)));
1124                }
1125            },
1126            ContentBlock::Audio { source } => {
1127                blocks.push(crate::codecs::openai_media::audio_part(source)?);
1128            }
1129            ContentBlock::Video { source } => {
1130                push_lossy(
1131                    diagnostics,
1132                    policy,
1133                    "OpenAI Chat codec does not have a stable video request mapping yet",
1134                )?;
1135                blocks.push(openai_text_part(&media_source_text(source)));
1136            }
1137            ContentBlock::Mcp(_) => {
1138                return Err(TranslationError::LossyConversion(
1139                    "MCP history encoding is not implemented".into(),
1140                ));
1141            }
1142            ContentBlock::Unknown { provider, raw } => {
1143                reject_responses_builtin_tool_item(provider, raw, WireFormat::OpenAiChat)?;
1144                push_lossy(
1145                    diagnostics,
1146                    policy,
1147                    "unknown content block encoded as text for OpenAI Chat",
1148                )?;
1149                blocks.push(openai_text_part(&json_string(raw)));
1150            }
1151            ContentBlock::Reasoning { .. }
1152            | ContentBlock::ToolCall(_)
1153            | ContentBlock::ToolResult(_) => {}
1154        }
1155    }
1156    Ok(Value::Array(blocks))
1157}
1158
1159// Builds an OpenAI text content part.
1160fn openai_text_part(text: &str) -> Value {
1161    json!({"type": "text", "text": text})
1162}
1163
1164fn openai_image_part(source: &ImageSource) -> Option<Value> {
1165    let ImagePayload { url, detail } = image_payload(source)?;
1166    let mut image_url = json!({});
1167    image_url["url"] = Value::String(url);
1168    if let Some(detail) = detail {
1169        image_url["detail"] = Value::String(detail);
1170    }
1171    let mut part = json!({"type": "image_url"});
1172    part["image_url"] = image_url;
1173    Some(part)
1174}
1175
1176fn openai_file_part(source: &FileSource) -> Option<Value> {
1177    let mut part = json!({"type": "file"});
1178    part["file"] = Value::Object(file_payload(source)?);
1179    Some(part)
1180}
1181
1182// Converts unsupported media sources to deterministic text fallback content.
1183fn media_source_text(source: &MediaSource) -> String {
1184    match source {
1185        MediaSource::Url { url, media_type } => json_string(&json!({
1186            "url": url,
1187            "media_type": media_type,
1188        })),
1189        MediaSource::Base64 { media_type, data } => json_string(&json!({
1190            "media_type": media_type,
1191            "data": data,
1192        })),
1193        MediaSource::Raw(raw) => json_string(raw),
1194    }
1195}
1196
1197/// Encodes normalized tool definitions into OpenAI tool JSON.
1198pub(crate) fn encode_openai_tools(tools: &[Tool]) -> Result<Value> {
1199    Ok(Value::Array(
1200        tools
1201            .iter()
1202            .map(|tool| {
1203                let tool = tool.function().ok_or_else(|| {
1204                    TranslationError::LossyConversion(
1205                        "MCP and provider tool encoding is not implemented".into(),
1206                    )
1207                })?;
1208                let mut function = json!({
1209                    "name": tool.name,
1210                    "description": tool.description.clone().unwrap_or_default(),
1211                    "parameters": tool.parameters,
1212                });
1213                if let Some(strict) = tool.strict {
1214                    function["strict"] = Value::Bool(strict);
1215                }
1216                Ok(json!({"type": "function", "function": function}))
1217            })
1218            .collect::<Result<_>>()?,
1219    ))
1220}
1221
1222/// Encodes normalized tool choice into OpenAI Chat JSON.
1223pub(crate) fn encode_openai_tool_choice(choice: &ToolChoice) -> Value {
1224    match choice {
1225        ToolChoice::Auto => Value::String("auto".to_string()),
1226        ToolChoice::Required => Value::String("required".to_string()),
1227        ToolChoice::None => Value::String("none".to_string()),
1228        ToolChoice::Tool { name } => json!({"type": "function", "function": {"name": name}}),
1229        ToolChoice::Raw(value) => value.clone(),
1230    }
1231}
1232
1233/// Normalizes OpenAI usage fields.
1234pub(crate) fn decode_openai_usage(value: Option<&Value>) -> Usage {
1235    let Some(value) = value.and_then(Value::as_object) else {
1236        return Usage::default();
1237    };
1238    let cached_input_tokens = value
1239        .get("prompt_tokens_details")
1240        .and_then(|details| details.get("cached_tokens"))
1241        .and_then(Value::as_u64);
1242    let cache_creation_input_tokens = value
1243        .get("prompt_tokens_details")
1244        .and_then(|details| {
1245            details
1246                .get("cache_write_tokens")
1247                .or_else(|| details.get("cache_creation_tokens"))
1248        })
1249        .and_then(Value::as_u64);
1250    Usage {
1251        provider_cost: crate::codecs::common::provider_cost(value),
1252        input_tokens: value
1253            .get("prompt_tokens")
1254            .and_then(Value::as_u64)
1255            .map(|tokens| {
1256                tokens
1257                    .saturating_sub(cached_input_tokens.unwrap_or(0))
1258                    .saturating_sub(cache_creation_input_tokens.unwrap_or(0))
1259            }),
1260        cache: crate::codecs::common::cache_usage_details(
1261            value,
1262            cached_input_tokens,
1263            cache_creation_input_tokens,
1264        ),
1265        output_tokens: value.get("completion_tokens").and_then(Value::as_u64),
1266        total_tokens: value.get("total_tokens").and_then(Value::as_u64),
1267        reasoning_tokens: value
1268            .get("completion_tokens_details")
1269            .and_then(|details| details.get("reasoning_tokens"))
1270            .or_else(|| {
1271                value
1272                    .get("output_tokens_details")
1273                    .and_then(|details| details.get("reasoning_tokens"))
1274            })
1275            .and_then(Value::as_u64),
1276    }
1277}
1278
1279/// Encodes normalized usage into OpenAI Chat usage JSON.
1280pub(crate) fn encode_openai_usage(usage: &Usage) -> Value {
1281    let prompt_tokens = usage.input_tokens.unwrap_or(0)
1282        + usage.cached_input_tokens().unwrap_or(0)
1283        + usage.cache_creation_input_tokens().unwrap_or(0);
1284    let mut value = json!({
1285        "prompt_tokens": prompt_tokens,
1286        "completion_tokens": usage.output_tokens.unwrap_or(0),
1287        "total_tokens": usage
1288            .total_tokens
1289            .or_else(|| Some(prompt_tokens + usage.output_tokens.unwrap_or(0)))
1290            .unwrap_or(0),
1291    });
1292    if usage.cached_input_tokens().is_some() || usage.cache_creation_input_tokens().is_some() {
1293        value["prompt_tokens_details"] = json!({
1294            "cached_tokens": usage.cached_input_tokens().unwrap_or(0),
1295            "cache_creation_tokens": usage.cache_creation_input_tokens().unwrap_or(0),
1296        });
1297    }
1298    if let Some(reasoning_tokens) = usage.reasoning_tokens {
1299        value["completion_tokens_details"] = json!({
1300            "reasoning_tokens": reasoning_tokens,
1301        });
1302    }
1303    crate::codecs::common::encode_usage_metadata(value, usage)
1304}
1305
1306/// Maps OpenAI finish reasons to normalized stop reasons.
1307pub(crate) fn map_openai_finish_reason(reason: Option<&str>) -> StopReason {
1308    match reason {
1309        Some("length") => StopReason::MaxTokens,
1310        Some("tool_calls") | Some("function_call") => StopReason::ToolUse,
1311        Some("content_filter") => StopReason::ContentFilter,
1312        Some("stop") | None => StopReason::EndTurn,
1313        _ => StopReason::Unknown,
1314    }
1315}
1316
1317/// Maps normalized stop reasons back to OpenAI's vocabulary.
1318pub(crate) fn openai_finish_reason(reason: StopReason) -> &'static str {
1319    match reason {
1320        StopReason::MaxTokens => "length",
1321        StopReason::ToolUse => "tool_calls",
1322        StopReason::ContentFilter => "content_filter",
1323        StopReason::EndTurn | StopReason::Unknown | StopReason::Error => "stop",
1324    }
1325}