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