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