Skip to main content

nemo_relay/codec/
openai_chat.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Built-in codec for the OpenAI Chat Completions API.
5//!
6//! Implements [`LlmCodec`] (request decode/encode) and [`LlmResponseCodec`]
7//! (response decode) for the OpenAI Chat Completions format.
8
9use serde::Deserialize;
10
11use crate::api::llm::LlmRequest;
12use crate::api::runtime::{BuiltinLlmCodec, LlmCodecIdentity};
13use crate::error::{FlowError, Result};
14use crate::json::Json;
15
16use super::request::{
17    AnnotatedLlmRequest, ApiSpecificRequest, ContentPart, FunctionCall, FunctionDefinition,
18    GenerationParams, Message, MessageContent, OpenAiImageUrl, ProviderNativeComponent, ToolCall,
19    ToolChoice, ToolDefinition,
20};
21use super::resolve::{ProviderSurface, ProviderSurfaceDescriptor};
22use super::response::{
23    AnnotatedLlmResponse, ApiSpecificResponse, FinishReason, RawUsageCost, ResponseToolCall, Usage,
24    estimate_cost_for_provider, infer_model_provider, provider_reported_cost,
25};
26use super::traits::{LlmCodec, LlmResponseCodec};
27
28// ---------------------------------------------------------------------------
29// Public codec struct
30// ---------------------------------------------------------------------------
31
32/// Built-in codec for the OpenAI Chat Completions API.
33pub struct OpenAIChatCodec;
34
35pub(crate) const PROVIDER_SURFACE: ProviderSurfaceDescriptor = ProviderSurfaceDescriptor {
36    surface: ProviderSurface::OpenAIChat,
37    detect_request: |obj, _| obj.contains_key("messages"),
38    detect_response: |obj| obj.get("choices").is_some_and(Json::is_array),
39    decode_request: |request| OpenAIChatCodec.decode(request),
40    decode_response: |raw| OpenAIChatCodec.decode_response(raw),
41    codec_name: "openai_chat",
42    request_codec: || std::sync::Arc::new(OpenAIChatCodec),
43    response_codec: || std::sync::Arc::new(OpenAIChatCodec),
44    streaming_codec: || Box::new(OpenAIChatStreamingCodec::new()),
45};
46
47// ---------------------------------------------------------------------------
48// Private intermediate serde structs for response decode
49// ---------------------------------------------------------------------------
50
51#[derive(Deserialize)]
52struct RawChatCompletion {
53    id: Option<String>,
54    model: Option<String>,
55    choices: Option<Vec<RawChoice>>,
56    usage: Option<RawChatUsage>,
57    system_fingerprint: Option<String>,
58    service_tier: Option<String>,
59    #[serde(flatten)]
60    extra: serde_json::Map<String, Json>,
61}
62
63#[derive(Deserialize)]
64struct RawChoice {
65    message: Option<RawMessage>,
66    finish_reason: Option<String>,
67    logprobs: Option<Json>,
68}
69
70#[derive(Deserialize)]
71struct RawMessage {
72    content: Option<String>,
73    tool_calls: Option<Vec<RawToolCall>>,
74}
75
76#[derive(Deserialize)]
77struct RawToolCall {
78    id: Option<String>,
79    function: Option<RawFunction>,
80}
81
82#[derive(Deserialize)]
83struct RawFunction {
84    name: Option<String>,
85    arguments: Option<String>,
86}
87
88#[derive(Deserialize)]
89struct RawChatUsage {
90    prompt_tokens: Option<u64>,
91    completion_tokens: Option<u64>,
92    total_tokens: Option<u64>,
93    prompt_tokens_details: Option<RawPromptTokensDetails>,
94    #[serde(rename = "cost_usd")]
95    provider_cost: Option<f64>,
96    cost: Option<RawUsageCost>,
97}
98
99#[derive(Deserialize)]
100struct RawPromptTokensDetails {
101    cached_tokens: Option<u64>,
102}
103
104// ---------------------------------------------------------------------------
105// Helper functions
106// ---------------------------------------------------------------------------
107
108/// Map OpenAI Chat finish_reason string to normalized [`FinishReason`].
109fn map_chat_finish_reason(reason: &str) -> FinishReason {
110    match reason {
111        "stop" => FinishReason::Complete,
112        "length" => FinishReason::Length,
113        "tool_calls" | "function_call" => FinishReason::ToolUse,
114        "content_filter" => FinishReason::ContentFilter,
115        other => FinishReason::Unknown(other.to_string()),
116    }
117}
118
119/// Parse OpenAI tool call arguments from JSON string to [`Json`] value.
120///
121/// Falls back to [`Json::String`] if parsing fails (malformed model output).
122fn parse_arguments(arguments: &str) -> Json {
123    serde_json::from_str(arguments).unwrap_or_else(|_| Json::String(arguments.to_string()))
124}
125
126/// Keys that are modeled in [`AnnotatedLlmRequest`] and should NOT go into `extra`.
127const MODELED_REQUEST_KEYS: &[&str] = &[
128    "messages",
129    "model",
130    "temperature",
131    "max_tokens",
132    "max_completion_tokens",
133    "top_p",
134    "stop",
135    "tools",
136    "tool_choice",
137    "store",
138    "user",
139    "metadata",
140    "service_tier",
141    "parallel_tool_calls",
142    "top_logprobs",
143    "stream",
144    "audio",
145    "frequency_penalty",
146    "function_call",
147    "functions",
148    "logit_bias",
149    "logprobs",
150    "modalities",
151    "moderation",
152    "n",
153    "prediction",
154    "presence_penalty",
155    "prompt_cache_key",
156    "prompt_cache_options",
157    "prompt_cache_retention",
158    "reasoning_effort",
159    "response_format",
160    "safety_identifier",
161    "seed",
162    "stream_options",
163    "verbosity",
164    "web_search_options",
165];
166
167fn chat_native(kind: &str, value: &Json) -> ProviderNativeComponent {
168    ProviderNativeComponent {
169        provider: "openai_chat".into(),
170        kind: kind.to_string(),
171        value: value.clone(),
172    }
173}
174
175fn decode_chat_content(value: &Json) -> Result<MessageContent> {
176    if let Some(text) = value.as_str() {
177        return Ok(MessageContent::Text(text.to_string()));
178    }
179    let parts = value.as_array().ok_or_else(|| {
180        FlowError::InvalidArgument("OpenAI Chat message content must be a string or array".into())
181    })?;
182    Ok(MessageContent::Parts(
183        parts
184            .iter()
185            .map(decode_chat_content_part)
186            .collect::<Result<Vec<_>>>()?,
187    ))
188}
189
190fn decode_chat_content_part(value: &Json) -> Result<ContentPart> {
191    let obj = value.as_object().ok_or_else(|| {
192        FlowError::InvalidArgument("OpenAI Chat content part must be an object".into())
193    })?;
194    let kind = obj.get("type").and_then(Json::as_str).unwrap_or("unknown");
195    match kind {
196        "text" => Ok(ContentPart::Text {
197            text: obj
198                .get("text")
199                .and_then(Json::as_str)
200                .ok_or_else(|| {
201                    FlowError::InvalidArgument("OpenAI Chat text part is missing text".into())
202                })?
203                .to_string(),
204            extra: obj
205                .iter()
206                .filter(|(key, _)| !matches!(key.as_str(), "type" | "text"))
207                .map(|(key, value)| (key.clone(), value.clone()))
208                .collect(),
209        }),
210        "image_url" => {
211            let image_url: OpenAiImageUrl =
212                serde_json::from_value(obj.get("image_url").cloned().ok_or_else(|| {
213                    FlowError::InvalidArgument("OpenAI Chat image part is missing image_url".into())
214                })?)
215                .map_err(|error| {
216                    FlowError::InvalidArgument(format!("invalid OpenAI Chat image_url: {error}"))
217                })?;
218            Ok(ContentPart::ImageUrl {
219                image_url,
220                extra: obj
221                    .iter()
222                    .filter(|(key, _)| !matches!(key.as_str(), "type" | "image_url"))
223                    .map(|(key, value)| (key.clone(), value.clone()))
224                    .collect(),
225            })
226        }
227        "input_audio" => Ok(ContentPart::Audio {
228            audio: obj.get("input_audio").cloned().ok_or_else(|| {
229                FlowError::InvalidArgument("OpenAI Chat audio part is missing input_audio".into())
230            })?,
231            extra: obj
232                .iter()
233                .filter(|(key, _)| !matches!(key.as_str(), "type" | "input_audio"))
234                .map(|(key, value)| (key.clone(), value.clone()))
235                .collect(),
236        }),
237        "file" => Ok(ContentPart::File {
238            file: obj.get("file").cloned().ok_or_else(|| {
239                FlowError::InvalidArgument("OpenAI Chat file part is missing file".into())
240            })?,
241            extra: obj
242                .iter()
243                .filter(|(key, _)| !matches!(key.as_str(), "type" | "file"))
244                .map(|(key, value)| (key.clone(), value.clone()))
245                .collect(),
246        }),
247        "refusal" => Ok(ContentPart::Refusal {
248            refusal: obj
249                .get("refusal")
250                .and_then(Json::as_str)
251                .ok_or_else(|| {
252                    FlowError::InvalidArgument("OpenAI Chat refusal part is missing refusal".into())
253                })?
254                .to_string(),
255            extra: obj
256                .iter()
257                .filter(|(key, _)| !matches!(key.as_str(), "type" | "refusal"))
258                .map(|(key, value)| (key.clone(), value.clone()))
259                .collect(),
260        }),
261        _ => Ok(ContentPart::ProviderNative {
262            provider: "openai_chat".into(),
263            kind: kind.to_string(),
264            value: value.clone(),
265        }),
266    }
267}
268
269fn optional_chat_string(
270    obj: &serde_json::Map<String, Json>,
271    key: &str,
272    context: &str,
273) -> Result<Option<String>> {
274    match obj.get(key) {
275        Some(Json::Null) | None => Ok(None),
276        Some(Json::String(value)) => Ok(Some(value.clone())),
277        Some(_) => Err(FlowError::InvalidArgument(format!(
278            "OpenAI Chat {context} {key} must be a string or null"
279        ))),
280    }
281}
282
283fn decode_chat_tool_call(value: &Json) -> Result<Option<ToolCall>> {
284    let obj = value.as_object().ok_or_else(|| {
285        FlowError::InvalidArgument("OpenAI Chat tool call must be an object".into())
286    })?;
287    if obj.get("type").and_then(Json::as_str) != Some("function") {
288        return Ok(None);
289    }
290    if obj
291        .keys()
292        .any(|key| !matches!(key.as_str(), "id" | "type" | "function"))
293    {
294        return Ok(None);
295    }
296    let function = obj
297        .get("function")
298        .and_then(Json::as_object)
299        .ok_or_else(|| {
300            FlowError::InvalidArgument("OpenAI Chat function tool call is missing function".into())
301        })?;
302    if function
303        .keys()
304        .any(|key| !matches!(key.as_str(), "name" | "arguments"))
305    {
306        return Ok(None);
307    }
308    let id = obj.get("id").and_then(Json::as_str).ok_or_else(|| {
309        FlowError::InvalidArgument("OpenAI Chat function tool call is missing id".into())
310    })?;
311    let name = function.get("name").and_then(Json::as_str).ok_or_else(|| {
312        FlowError::InvalidArgument("OpenAI Chat function tool call is missing name".into())
313    })?;
314    let arguments = function
315        .get("arguments")
316        .and_then(Json::as_str)
317        .ok_or_else(|| {
318            FlowError::InvalidArgument("OpenAI Chat function tool call is missing arguments".into())
319        })?;
320    Ok(Some(ToolCall {
321        id: id.to_string(),
322        call_type: "function".into(),
323        function: FunctionCall {
324            name: name.to_string(),
325            arguments: arguments.to_string(),
326        },
327    }))
328}
329
330fn decode_chat_message(value: &Json) -> Result<Message> {
331    let obj = value.as_object().ok_or_else(|| {
332        FlowError::InvalidArgument("OpenAI Chat message must be an object".into())
333    })?;
334    let role = obj
335        .get("role")
336        .and_then(Json::as_str)
337        .ok_or_else(|| FlowError::InvalidArgument("OpenAI Chat message is missing role".into()))?;
338    let native = || Message::ProviderNative {
339        provider: "openai_chat".into(),
340        kind: role.to_string(),
341        value: value.clone(),
342    };
343    match role {
344        "system" | "developer" | "user" => {
345            if obj
346                .keys()
347                .any(|key| !matches!(key.as_str(), "role" | "content" | "name"))
348            {
349                return Ok(native());
350            }
351            let content = decode_chat_content(obj.get("content").ok_or_else(|| {
352                FlowError::InvalidArgument("OpenAI Chat message is missing content".into())
353            })?)?;
354            let name = optional_chat_string(obj, "name", "message")?;
355            Ok(match role {
356                "system" => Message::System { content, name },
357                "developer" => Message::Developer { content, name },
358                _ => Message::User { content, name },
359            })
360        }
361        "assistant" => {
362            if obj
363                .keys()
364                .any(|key| !matches!(key.as_str(), "role" | "content" | "tool_calls" | "name"))
365            {
366                return Ok(native());
367            }
368            let content = obj
369                .get("content")
370                .filter(|content| !content.is_null())
371                .map(decode_chat_content)
372                .transpose()?;
373            let tool_calls = match obj.get("tool_calls") {
374                Some(Json::Null) | None => None,
375                Some(Json::Array(calls)) => {
376                    let decoded = calls
377                        .iter()
378                        .map(decode_chat_tool_call)
379                        .collect::<Result<Vec<_>>>()?;
380                    let Some(decoded) = decoded.into_iter().collect::<Option<Vec<_>>>() else {
381                        return Ok(native());
382                    };
383                    Some(decoded)
384                }
385                Some(_) => {
386                    return Err(FlowError::InvalidArgument(
387                        "OpenAI Chat assistant tool_calls must be an array or null".into(),
388                    ));
389                }
390            };
391            Ok(Message::Assistant {
392                content,
393                tool_calls,
394                name: optional_chat_string(obj, "name", "assistant message")?,
395            })
396        }
397        "tool"
398            if obj
399                .keys()
400                .all(|key| matches!(key.as_str(), "role" | "content" | "tool_call_id")) =>
401        {
402            Ok(Message::Tool {
403                content: decode_chat_content(obj.get("content").ok_or_else(|| {
404                    FlowError::InvalidArgument("OpenAI Chat tool message is missing content".into())
405                })?)?,
406                tool_call_id: obj
407                    .get("tool_call_id")
408                    .and_then(Json::as_str)
409                    .ok_or_else(|| {
410                        FlowError::InvalidArgument(
411                            "OpenAI Chat tool message is missing tool_call_id".into(),
412                        )
413                    })?
414                    .to_string(),
415            })
416        }
417        "function"
418            if obj
419                .keys()
420                .all(|key| matches!(key.as_str(), "role" | "content" | "name")) =>
421        {
422            Ok(Message::Function {
423                content: optional_chat_string(obj, "content", "function message")?,
424                name: obj
425                    .get("name")
426                    .and_then(Json::as_str)
427                    .ok_or_else(|| {
428                        FlowError::InvalidArgument(
429                            "OpenAI Chat function message is missing name".into(),
430                        )
431                    })?
432                    .to_string(),
433            })
434        }
435        _ => Ok(native()),
436    }
437}
438
439fn encode_chat_content(content: &MessageContent) -> Result<Json> {
440    match content {
441        MessageContent::Text(text) => Ok(Json::String(text.clone())),
442        MessageContent::Parts(parts) => Ok(Json::Array(
443            parts
444                .iter()
445                .map(|part| match part {
446                    ContentPart::Text { text, extra } => {
447                        let mut obj = extra.clone();
448                        obj.insert("type".into(), Json::String("text".into()));
449                        obj.insert("text".into(), Json::String(text.clone()));
450                        Ok(Json::Object(obj))
451                    }
452                    ContentPart::ImageUrl { image_url, extra } => {
453                        let mut obj = extra.clone();
454                        obj.insert("type".into(), Json::String("image_url".into()));
455                        obj.insert(
456                            "image_url".into(),
457                            serde_json::to_value(image_url).map_err(|error| {
458                                FlowError::Internal(format!(
459                                    "OpenAI Chat image URL encode: {error}"
460                                ))
461                            })?,
462                        );
463                        Ok(Json::Object(obj))
464                    }
465                    ContentPart::Audio { audio, extra } => {
466                        let mut obj = extra.clone();
467                        obj.insert("type".into(), Json::String("input_audio".into()));
468                        obj.insert("input_audio".into(), audio.clone());
469                        Ok(Json::Object(obj))
470                    }
471                    ContentPart::File { file, extra } => {
472                        let mut obj = extra.clone();
473                        obj.insert("type".into(), Json::String("file".into()));
474                        obj.insert("file".into(), file.clone());
475                        Ok(Json::Object(obj))
476                    }
477                    ContentPart::Refusal { refusal, extra } => {
478                        let mut obj = extra.clone();
479                        obj.insert("type".into(), Json::String("refusal".into()));
480                        obj.insert("refusal".into(), Json::String(refusal.clone()));
481                        Ok(Json::Object(obj))
482                    }
483                    ContentPart::ProviderNative {
484                        provider, value, ..
485                    } if provider == "openai_chat" => Ok(value.clone()),
486                    other => Err(FlowError::InvalidArgument(format!(
487                        "content part {other:?} cannot be encoded for OpenAI Chat"
488                    ))),
489                })
490                .collect::<Result<Vec<_>>>()?,
491        )),
492    }
493}
494
495fn encode_chat_message(message: &Message) -> Result<Json> {
496    let message_with_content =
497        |role: &str, content: &MessageContent, name: &Option<String>| -> Result<Json> {
498            let mut obj = serde_json::Map::new();
499            obj.insert("role".into(), Json::String(role.into()));
500            obj.insert("content".into(), encode_chat_content(content)?);
501            if let Some(name) = name {
502                obj.insert("name".into(), Json::String(name.clone()));
503            }
504            Ok(Json::Object(obj))
505        };
506    match message {
507        Message::System { content, name } => message_with_content("system", content, name),
508        Message::Developer { content, name } => message_with_content("developer", content, name),
509        Message::User { content, name } => message_with_content("user", content, name),
510        Message::Assistant {
511            content,
512            tool_calls,
513            name,
514        } => {
515            let mut obj = serde_json::Map::new();
516            obj.insert("role".into(), Json::String("assistant".into()));
517            if let Some(content) = content {
518                obj.insert("content".into(), encode_chat_content(content)?);
519            }
520            if let Some(tool_calls) = tool_calls {
521                obj.insert(
522                    "tool_calls".into(),
523                    serde_json::to_value(tool_calls).map_err(|error| {
524                        FlowError::Internal(format!("OpenAI Chat tool calls encode: {error}"))
525                    })?,
526                );
527            }
528            if let Some(name) = name {
529                obj.insert("name".into(), Json::String(name.clone()));
530            }
531            Ok(Json::Object(obj))
532        }
533        Message::Tool {
534            content,
535            tool_call_id,
536        } => {
537            let mut obj = serde_json::Map::new();
538            obj.insert("role".into(), Json::String("tool".into()));
539            obj.insert("content".into(), encode_chat_content(content)?);
540            obj.insert("tool_call_id".into(), Json::String(tool_call_id.clone()));
541            Ok(Json::Object(obj))
542        }
543        Message::Function { content, name } => {
544            let mut obj = serde_json::Map::new();
545            obj.insert("role".into(), Json::String("function".into()));
546            obj.insert(
547                "content".into(),
548                content.clone().map(Json::String).unwrap_or(Json::Null),
549            );
550            obj.insert("name".into(), Json::String(name.clone()));
551            Ok(Json::Object(obj))
552        }
553        Message::ProviderNative {
554            provider, value, ..
555        } if provider == "openai_chat" => Ok(value.clone()),
556        other => Err(FlowError::InvalidArgument(format!(
557            "message {other:?} cannot be encoded for OpenAI Chat"
558        ))),
559    }
560}
561
562fn decode_chat_tool(value: &Json) -> Result<ToolDefinition> {
563    let obj = value
564        .as_object()
565        .ok_or_else(|| FlowError::InvalidArgument("OpenAI Chat tool must be an object".into()))?;
566    if obj.get("type").and_then(Json::as_str) != Some("function") {
567        let native = chat_native(
568            obj.get("type").and_then(Json::as_str).unwrap_or("unknown"),
569            value,
570        );
571        return Ok(ToolDefinition::ProviderNative {
572            provider: native.provider,
573            kind: native.kind,
574            value: native.value,
575        });
576    }
577    let function = obj
578        .get("function")
579        .and_then(Json::as_object)
580        .ok_or_else(|| {
581            FlowError::InvalidArgument("OpenAI Chat function tool is missing function".into())
582        })?;
583    let name = function.get("name").and_then(Json::as_str).ok_or_else(|| {
584        FlowError::InvalidArgument("OpenAI Chat function tool is missing name".into())
585    })?;
586    let description = super::optional_string(function, "description", "OpenAI Chat function tool")?;
587    let strict = super::optional_bool(function, "strict", "OpenAI Chat function tool")?;
588    Ok(ToolDefinition::Function {
589        function: FunctionDefinition {
590            name: name.to_string(),
591            description,
592            parameters: function.get("parameters").cloned(),
593            strict,
594            extra: function
595                .iter()
596                .filter(|(key, _)| {
597                    !matches!(
598                        key.as_str(),
599                        "name" | "description" | "parameters" | "strict"
600                    )
601                })
602                .map(|(key, value)| (key.clone(), value.clone()))
603                .collect(),
604        },
605        extra: obj
606            .iter()
607            .filter(|(key, _)| !matches!(key.as_str(), "type" | "function"))
608            .map(|(key, value)| (key.clone(), value.clone()))
609            .collect(),
610    })
611}
612
613fn encode_chat_tool(tool: &ToolDefinition) -> Result<Json> {
614    match tool {
615        ToolDefinition::Function { function, extra } => {
616            let mut function_obj = function.extra.clone();
617            function_obj.insert("name".into(), Json::String(function.name.clone()));
618            if let Some(description) = &function.description {
619                function_obj.insert("description".into(), Json::String(description.clone()));
620            }
621            if let Some(parameters) = &function.parameters {
622                function_obj.insert("parameters".into(), parameters.clone());
623            }
624            if let Some(strict) = function.strict {
625                function_obj.insert("strict".into(), Json::Bool(strict));
626            }
627            let mut obj = extra.clone();
628            obj.insert("type".into(), Json::String("function".into()));
629            obj.insert("function".into(), Json::Object(function_obj));
630            Ok(Json::Object(obj))
631        }
632        ToolDefinition::ProviderNative {
633            provider, value, ..
634        } if provider == "openai_chat" => Ok(value.clone()),
635        other => Err(FlowError::InvalidArgument(format!(
636            "tool {other:?} cannot be encoded for OpenAI Chat"
637        ))),
638    }
639}
640
641fn decode_chat_tool_choice(value: &Json) -> ToolChoice {
642    match value.as_str() {
643        Some("auto") => ToolChoice::Auto,
644        Some("none") => ToolChoice::None,
645        Some("required") => ToolChoice::Required,
646        _ => {
647            if let Some(function) = value
648                .as_object()
649                .filter(|obj| obj.get("type").and_then(Json::as_str) == Some("function"))
650                .and_then(|obj| obj.get("function"))
651                .and_then(Json::as_object)
652                .and_then(|function| function.get("name"))
653                .and_then(Json::as_str)
654            {
655                ToolChoice::Specific(super::request::ToolChoiceFunction {
656                    choice_type: "function".into(),
657                    function: super::request::ToolChoiceFunctionName {
658                        name: function.to_string(),
659                    },
660                })
661            } else {
662                ToolChoice::ProviderNative(chat_native("tool_choice", value))
663            }
664        }
665    }
666}
667
668fn encode_chat_tool_choice(choice: &ToolChoice) -> Result<Json> {
669    match choice {
670        ToolChoice::Auto => Ok(Json::String("auto".into())),
671        ToolChoice::None => Ok(Json::String("none".into())),
672        ToolChoice::Required => Ok(Json::String("required".into())),
673        ToolChoice::Specific(choice) => Ok(serde_json::json!({
674            "type":"function",
675            "function":{"name":choice.function.name}
676        })),
677        ToolChoice::ProviderNative(native) if native.provider == "openai_chat" => {
678            Ok(native.value.clone())
679        }
680        ToolChoice::ProviderNative(native) => Err(FlowError::InvalidArgument(format!(
681            "tool choice for {} cannot be encoded for OpenAI Chat",
682            native.provider
683        ))),
684    }
685}
686
687fn patch_extra_fields(
688    obj: &mut serde_json::Map<String, Json>,
689    baseline: &serde_json::Map<String, Json>,
690    edited: &serde_json::Map<String, Json>,
691) {
692    for key in baseline.keys().filter(|key| !edited.contains_key(*key)) {
693        obj.remove(key);
694    }
695    for (key, value) in edited {
696        if baseline.get(key) != Some(value) {
697            obj.insert(key.clone(), value.clone());
698        }
699    }
700}
701
702fn set_or_remove_json(obj: &mut serde_json::Map<String, Json>, key: &str, value: Option<Json>) {
703    if let Some(value) = value {
704        obj.insert(key.into(), value);
705    } else {
706        obj.remove(key);
707    }
708}
709
710fn patch_chat_messages_and_validate(
711    obj: &mut serde_json::Map<String, Json>,
712    annotated: &AnnotatedLlmRequest,
713    baseline: &AnnotatedLlmRequest,
714) -> Result<()> {
715    if annotated.messages != baseline.messages {
716        let original_messages = obj.get("messages").and_then(Json::as_array);
717        obj.insert(
718            "messages".into(),
719            Json::Array(super::encode_changed_items(
720                &annotated.messages,
721                &baseline.messages,
722                original_messages.map(Vec::as_slice),
723                encode_chat_message,
724            )?),
725        );
726    }
727    let unsupported = [
728        annotated.instructions != baseline.instructions,
729        annotated.previous_response_id != baseline.previous_response_id,
730        annotated.truncation != baseline.truncation,
731        annotated.reasoning != baseline.reasoning,
732        annotated.include != baseline.include,
733        annotated.max_output_tokens != baseline.max_output_tokens,
734        annotated.max_tool_calls != baseline.max_tool_calls,
735    ]
736    .into_iter()
737    .any(|changed| changed);
738    if unsupported {
739        return Err(FlowError::InvalidArgument(
740            "request contains fields that cannot be encoded for OpenAI Chat".into(),
741        ));
742    }
743    Ok(())
744}
745
746fn patch_chat_model_and_params(
747    obj: &mut serde_json::Map<String, Json>,
748    annotated: &AnnotatedLlmRequest,
749    baseline: &AnnotatedLlmRequest,
750) {
751    if annotated.model != baseline.model {
752        set_or_remove_json(obj, "model", annotated.model.clone().map(Json::String));
753    }
754    if annotated.params == baseline.params {
755        return;
756    }
757    let edited = annotated.params.as_ref();
758    let before = baseline.params.as_ref();
759    patch_chat_optional_params(obj, edited, before);
760    patch_chat_max_tokens(obj, edited, before);
761    patch_chat_stop_sequences(obj, edited, before);
762}
763
764fn patch_chat_optional_params(
765    obj: &mut serde_json::Map<String, Json>,
766    edited: Option<&GenerationParams>,
767    before: Option<&GenerationParams>,
768) {
769    for (key, value, old_value) in [
770        (
771            "temperature",
772            edited.and_then(|params| params.temperature),
773            before.and_then(|params| params.temperature),
774        ),
775        (
776            "top_p",
777            edited.and_then(|params| params.top_p),
778            before.and_then(|params| params.top_p),
779        ),
780    ] {
781        if value != old_value {
782            set_or_remove_json(obj, key, value.map(json_f64));
783        }
784    }
785}
786
787fn patch_chat_max_tokens(
788    obj: &mut serde_json::Map<String, Json>,
789    edited: Option<&GenerationParams>,
790    before: Option<&GenerationParams>,
791) {
792    let max_tokens = edited.and_then(|params| params.max_tokens);
793    if max_tokens == before.and_then(|params| params.max_tokens) {
794        return;
795    }
796    let max_tokens = max_tokens.map(Json::from);
797    if max_tokens.is_none() {
798        obj.remove("max_completion_tokens");
799        obj.remove("max_tokens");
800        return;
801    }
802    let key = if obj.contains_key("max_completion_tokens") || !obj.contains_key("max_tokens") {
803        "max_completion_tokens"
804    } else {
805        "max_tokens"
806    };
807    set_or_remove_json(obj, key, max_tokens);
808}
809
810fn patch_chat_stop_sequences(
811    obj: &mut serde_json::Map<String, Json>,
812    edited: Option<&GenerationParams>,
813    before: Option<&GenerationParams>,
814) {
815    let stop = edited.and_then(|params| params.stop.as_ref());
816    if stop == before.and_then(|params| params.stop.as_ref()) {
817        return;
818    }
819    let stop = stop.map(|values| {
820        if obj.get("stop").is_some_and(Json::is_string) && values.len() == 1 {
821            Json::String(values[0].clone())
822        } else {
823            serde_json::json!(values)
824        }
825    });
826    set_or_remove_json(obj, "stop", stop);
827}
828
829fn patch_chat_tools(
830    obj: &mut serde_json::Map<String, Json>,
831    annotated: &AnnotatedLlmRequest,
832    baseline: &AnnotatedLlmRequest,
833) -> Result<()> {
834    if annotated.tools != baseline.tools {
835        let tools = annotated
836            .tools
837            .as_deref()
838            .map(|tools| {
839                super::encode_changed_items(
840                    tools,
841                    baseline.tools.as_deref().unwrap_or(&[]),
842                    obj.get("tools").and_then(Json::as_array).map(Vec::as_slice),
843                    encode_chat_tool,
844                )
845            })
846            .transpose()?
847            .map(Json::Array);
848        set_or_remove_json(obj, "tools", tools);
849    }
850    if annotated.tool_choice != baseline.tool_choice {
851        let tool_choice = match (&annotated.tool_choice, &baseline.tool_choice) {
852            (Some(edited), Some(before)) => {
853                let edited = encode_chat_tool_choice(edited)?;
854                let before = encode_chat_tool_choice(before)?;
855                Some(match obj.get("tool_choice") {
856                    Some(original) => super::patch_changed_json(original, &before, &edited)?,
857                    None => edited,
858                })
859            }
860            (Some(edited), None) => Some(encode_chat_tool_choice(edited)?),
861            (None, _) => None,
862        };
863        set_or_remove_json(obj, "tool_choice", tool_choice);
864    }
865    Ok(())
866}
867
868fn patch_chat_common_fields(
869    obj: &mut serde_json::Map<String, Json>,
870    annotated: &AnnotatedLlmRequest,
871    baseline: &AnnotatedLlmRequest,
872) {
873    if annotated.metadata != baseline.metadata {
874        set_or_remove_json(obj, "metadata", annotated.metadata.clone());
875    }
876    for (key, edited, before) in [
877        ("store", annotated.store, baseline.store),
878        (
879            "parallel_tool_calls",
880            annotated.parallel_tool_calls,
881            baseline.parallel_tool_calls,
882        ),
883        ("stream", annotated.stream, baseline.stream),
884    ] {
885        if edited != before {
886            set_or_remove_json(obj, key, edited.map(Json::Bool));
887        }
888    }
889    for (key, edited, before) in [
890        ("user", &annotated.user, &baseline.user),
891        (
892            "service_tier",
893            &annotated.service_tier,
894            &baseline.service_tier,
895        ),
896    ] {
897        if edited != before {
898            set_or_remove_json(obj, key, edited.clone().map(Json::String));
899        }
900    }
901    if annotated.top_logprobs != baseline.top_logprobs {
902        set_or_remove_json(obj, "top_logprobs", annotated.top_logprobs.map(Json::from));
903    }
904}
905
906fn patch_optional_json_fields(
907    obj: &mut serde_json::Map<String, Json>,
908    fields: &[(&str, &Option<Json>, &Option<Json>)],
909) {
910    for (key, edited, before) in fields {
911        if edited != before {
912            set_or_remove_json(obj, key, (*edited).clone());
913        }
914    }
915}
916
917fn patch_optional_string_fields(
918    obj: &mut serde_json::Map<String, Json>,
919    fields: &[(&str, &Option<String>, &Option<String>)],
920) {
921    for (key, edited, before) in fields {
922        if edited != before {
923            set_or_remove_json(obj, key, (*edited).clone().map(Json::String));
924        }
925    }
926}
927
928fn patch_optional_f64_fields(
929    obj: &mut serde_json::Map<String, Json>,
930    fields: &[(&str, &Option<f64>, &Option<f64>)],
931) {
932    for (key, edited, before) in fields {
933        if edited != before {
934            set_or_remove_json(obj, key, (*edited).map(json_f64));
935        }
936    }
937}
938
939fn patch_chat_api_specific(
940    obj: &mut serde_json::Map<String, Json>,
941    edited: &Option<ApiSpecificRequest>,
942    baseline: &Option<ApiSpecificRequest>,
943) -> Result<()> {
944    match (edited, baseline) {
945        (
946            Some(edited @ ApiSpecificRequest::OpenAIChat { .. }),
947            Some(baseline @ ApiSpecificRequest::OpenAIChat { .. }),
948        ) => {
949            patch_chat_api_fields(obj, edited, baseline);
950            Ok(())
951        }
952        (None, Some(ApiSpecificRequest::OpenAIChat { .. })) => {
953            for key in [
954                "audio",
955                "frequency_penalty",
956                "function_call",
957                "functions",
958                "logit_bias",
959                "logprobs",
960                "modalities",
961                "moderation",
962                "n",
963                "prediction",
964                "presence_penalty",
965                "prompt_cache_key",
966                "prompt_cache_options",
967                "prompt_cache_retention",
968                "reasoning_effort",
969                "response_format",
970                "safety_identifier",
971                "seed",
972                "stream_options",
973                "verbosity",
974                "web_search_options",
975            ] {
976                obj.remove(key);
977            }
978            Ok(())
979        }
980        (Some(_), _) | (None, Some(_)) => Err(FlowError::InvalidArgument(
981            "api_specific provider does not match OpenAI Chat".into(),
982        )),
983        (None, None) => Ok(()),
984    }
985}
986
987fn patch_chat_api_fields(
988    obj: &mut serde_json::Map<String, Json>,
989    edited: &ApiSpecificRequest,
990    baseline: &ApiSpecificRequest,
991) {
992    let (
993        ApiSpecificRequest::OpenAIChat {
994            audio,
995            frequency_penalty,
996            function_call,
997            functions,
998            logit_bias,
999            logprobs,
1000            modalities,
1001            moderation,
1002            n,
1003            prediction,
1004            presence_penalty,
1005            prompt_cache_key,
1006            prompt_cache_options,
1007            prompt_cache_retention,
1008            reasoning_effort,
1009            response_format,
1010            safety_identifier,
1011            seed,
1012            stream_options,
1013            verbosity,
1014            web_search_options,
1015        },
1016        ApiSpecificRequest::OpenAIChat {
1017            audio: old_audio,
1018            frequency_penalty: old_frequency_penalty,
1019            function_call: old_function_call,
1020            functions: old_functions,
1021            logit_bias: old_logit_bias,
1022            logprobs: old_logprobs,
1023            modalities: old_modalities,
1024            moderation: old_moderation,
1025            n: old_n,
1026            prediction: old_prediction,
1027            presence_penalty: old_presence_penalty,
1028            prompt_cache_key: old_prompt_cache_key,
1029            prompt_cache_options: old_prompt_cache_options,
1030            prompt_cache_retention: old_prompt_cache_retention,
1031            reasoning_effort: old_reasoning_effort,
1032            response_format: old_response_format,
1033            safety_identifier: old_safety_identifier,
1034            seed: old_seed,
1035            stream_options: old_stream_options,
1036            verbosity: old_verbosity,
1037            web_search_options: old_web_search_options,
1038        },
1039    ) = (edited, baseline)
1040    else {
1041        unreachable!("OpenAI Chat variants checked by caller");
1042    };
1043    patch_optional_json_fields(
1044        obj,
1045        &[
1046            ("audio", audio, old_audio),
1047            ("function_call", function_call, old_function_call),
1048            ("logit_bias", logit_bias, old_logit_bias),
1049            ("moderation", moderation, old_moderation),
1050            ("prediction", prediction, old_prediction),
1051            (
1052                "prompt_cache_options",
1053                prompt_cache_options,
1054                old_prompt_cache_options,
1055            ),
1056            ("response_format", response_format, old_response_format),
1057            ("stream_options", stream_options, old_stream_options),
1058            (
1059                "web_search_options",
1060                web_search_options,
1061                old_web_search_options,
1062            ),
1063        ],
1064    );
1065    patch_optional_f64_fields(
1066        obj,
1067        &[
1068            (
1069                "frequency_penalty",
1070                frequency_penalty,
1071                old_frequency_penalty,
1072            ),
1073            ("presence_penalty", presence_penalty, old_presence_penalty),
1074        ],
1075    );
1076    patch_optional_string_fields(
1077        obj,
1078        &[
1079            ("prompt_cache_key", prompt_cache_key, old_prompt_cache_key),
1080            (
1081                "prompt_cache_retention",
1082                prompt_cache_retention,
1083                old_prompt_cache_retention,
1084            ),
1085            ("reasoning_effort", reasoning_effort, old_reasoning_effort),
1086            (
1087                "safety_identifier",
1088                safety_identifier,
1089                old_safety_identifier,
1090            ),
1091            ("verbosity", verbosity, old_verbosity),
1092        ],
1093    );
1094    if functions != old_functions {
1095        set_or_remove_json(obj, "functions", functions.clone().map(Json::Array));
1096    }
1097    if modalities != old_modalities {
1098        set_or_remove_json(
1099            obj,
1100            "modalities",
1101            modalities.as_ref().map(|value| serde_json::json!(value)),
1102        );
1103    }
1104    if logprobs != old_logprobs {
1105        set_or_remove_json(obj, "logprobs", logprobs.map(Json::Bool));
1106    }
1107    if n != old_n {
1108        set_or_remove_json(obj, "n", n.map(Json::from));
1109    }
1110    if seed != old_seed {
1111        set_or_remove_json(obj, "seed", seed.map(Json::from));
1112    }
1113}
1114
1115// ---------------------------------------------------------------------------
1116// LlmResponseCodec implementation
1117// ---------------------------------------------------------------------------
1118
1119impl LlmResponseCodec for OpenAIChatCodec {
1120    fn codec_identity(&self) -> LlmCodecIdentity {
1121        LlmCodecIdentity::BuiltIn(BuiltinLlmCodec::OpenAiChat)
1122    }
1123
1124    fn decode_response(&self, response: &Json) -> Result<AnnotatedLlmResponse> {
1125        let raw: RawChatCompletion = serde_json::from_value(response.clone())
1126            .map_err(|e| FlowError::Internal(format!("OpenAI Chat response decode: {e}")))?;
1127
1128        // Extract first choice (if any).
1129        let choice = raw.choices.as_ref().and_then(|c| c.first());
1130
1131        // Map message content.
1132        let message = choice
1133            .and_then(|c| c.message.as_ref())
1134            .and_then(|m| m.content.as_ref())
1135            .map(|s| super::request::MessageContent::Text(s.clone()));
1136
1137        // Map tool calls, skipping entries that lack a usable function body.
1138        // Some providers (proxies, vLLM, NIM) may return partial tool_calls
1139        // entries where `function` or `function.name` is absent or null.
1140        let tool_calls = choice
1141            .and_then(|c| c.message.as_ref())
1142            .and_then(|m| m.tool_calls.as_ref())
1143            .map(|tcs| {
1144                tcs.iter()
1145                    .filter_map(|tc| {
1146                        let func = tc.function.as_ref()?;
1147                        let name = func.name.as_ref()?;
1148                        Some(ResponseToolCall {
1149                            id: tc.id.clone().unwrap_or_default(),
1150                            name: name.clone(),
1151                            arguments: func
1152                                .arguments
1153                                .as_deref()
1154                                .map(parse_arguments)
1155                                .unwrap_or(Json::Object(Default::default())),
1156                        })
1157                    })
1158                    .collect::<Vec<_>>()
1159            });
1160
1161        // Map finish reason.
1162        let finish_reason = choice
1163            .and_then(|c| c.finish_reason.as_deref())
1164            .map(map_chat_finish_reason);
1165
1166        // Map usage.
1167        let model_for_pricing = raw.model.as_deref();
1168        let model_provider = infer_model_provider("openai", model_for_pricing);
1169        let usage = raw.usage.map(|u| {
1170            let mut usage = Usage {
1171                prompt_tokens: u.prompt_tokens,
1172                completion_tokens: u.completion_tokens,
1173                total_tokens: u.total_tokens,
1174                cache_read_tokens: u.prompt_tokens_details.and_then(|d| d.cached_tokens),
1175                cache_write_tokens: None,
1176                cost: provider_reported_cost(u.provider_cost, u.cost),
1177            };
1178            if usage.cost.is_none() {
1179                usage.cost = model_for_pricing.and_then(|model| {
1180                    estimate_cost_for_provider(model_provider.as_deref(), model, &usage)
1181                });
1182            }
1183            usage
1184        });
1185
1186        // Build API-specific fields.
1187        let logprobs = choice.and_then(|c| c.logprobs.clone());
1188        let api_specific = Some(ApiSpecificResponse::OpenAIChat {
1189            logprobs,
1190            system_fingerprint: raw.system_fingerprint,
1191            service_tier: raw.service_tier,
1192        });
1193
1194        Ok(AnnotatedLlmResponse {
1195            id: raw.id,
1196            model: raw.model,
1197            message,
1198            tool_calls,
1199            finish_reason,
1200            usage,
1201            optimization_summary: None,
1202            api_specific,
1203            extra: raw.extra,
1204        })
1205    }
1206}
1207
1208// ---------------------------------------------------------------------------
1209// LlmCodec implementation
1210// ---------------------------------------------------------------------------
1211
1212impl LlmCodec for OpenAIChatCodec {
1213    fn codec_identity(&self) -> LlmCodecIdentity {
1214        LlmCodecIdentity::BuiltIn(BuiltinLlmCodec::OpenAiChat)
1215    }
1216
1217    fn decode(&self, request: &LlmRequest) -> Result<AnnotatedLlmRequest> {
1218        let obj = request
1219            .content
1220            .as_object()
1221            .ok_or_else(|| FlowError::Internal("request content is not an object".into()))?;
1222        let messages = obj
1223            .get("messages")
1224            .ok_or_else(|| {
1225                FlowError::InvalidArgument("OpenAI Chat request is missing messages".into())
1226            })?
1227            .as_array()
1228            .ok_or_else(|| {
1229                FlowError::InvalidArgument("OpenAI Chat messages must be an array".into())
1230            })?
1231            .iter()
1232            .map(decode_chat_message)
1233            .collect::<Result<Vec<_>>>()?;
1234        let model = super::optional_string(obj, "model", "OpenAI Chat")?;
1235        let temperature = super::optional_f64(obj, "temperature", "OpenAI Chat")?;
1236        let top_p = super::optional_f64(obj, "top_p", "OpenAI Chat")?;
1237        let stop = match obj.get("stop") {
1238            Some(Json::String(stop)) => Some(vec![stop.clone()]),
1239            Some(Json::Array(_)) => Some(
1240                serde_json::from_value::<Vec<String>>(obj["stop"].clone()).map_err(|error| {
1241                    FlowError::InvalidArgument(format!("invalid OpenAI Chat stop value: {error}"))
1242                })?,
1243            ),
1244            Some(Json::Null) | None => None,
1245            Some(_) => {
1246                return Err(FlowError::InvalidArgument(
1247                    "OpenAI Chat stop must be a string, array, or null".into(),
1248                ));
1249            }
1250        };
1251        let max_tokens = super::optional_u64(obj, "max_completion_tokens", "OpenAI Chat")?
1252            .or(super::optional_u64(obj, "max_tokens", "OpenAI Chat")?);
1253        let params =
1254            if temperature.is_some() || max_tokens.is_some() || top_p.is_some() || stop.is_some() {
1255                Some(GenerationParams {
1256                    temperature,
1257                    max_tokens,
1258                    top_p,
1259                    stop,
1260                })
1261            } else {
1262                None
1263            };
1264        let tools = obj
1265            .get("tools")
1266            .map(|value| {
1267                value
1268                    .as_array()
1269                    .ok_or_else(|| {
1270                        FlowError::InvalidArgument("OpenAI Chat tools must be an array".into())
1271                    })?
1272                    .iter()
1273                    .map(decode_chat_tool)
1274                    .collect::<Result<Vec<_>>>()
1275            })
1276            .transpose()?;
1277        let tool_choice = obj.get("tool_choice").map(decode_chat_tool_choice);
1278        let store = super::optional_bool(obj, "store", "OpenAI Chat")?;
1279        let user = super::optional_string(obj, "user", "OpenAI Chat")?;
1280        let service_tier = super::optional_string(obj, "service_tier", "OpenAI Chat")?;
1281        let parallel_tool_calls = super::optional_bool(obj, "parallel_tool_calls", "OpenAI Chat")?;
1282        let top_logprobs = super::optional_u64(obj, "top_logprobs", "OpenAI Chat")?;
1283        let stream = super::optional_bool(obj, "stream", "OpenAI Chat")?;
1284        let frequency_penalty = super::optional_f64(obj, "frequency_penalty", "OpenAI Chat")?;
1285        let functions = match obj.get("functions") {
1286            Some(Json::Null) | None => None,
1287            Some(Json::Array(functions)) => Some(functions.clone()),
1288            Some(_) => {
1289                return Err(FlowError::InvalidArgument(
1290                    "OpenAI Chat functions must be an array or null".into(),
1291                ));
1292            }
1293        };
1294        let logprobs = super::optional_bool(obj, "logprobs", "OpenAI Chat")?;
1295        let modalities = match obj.get("modalities") {
1296            Some(Json::Null) | None => None,
1297            Some(value) => Some(serde_json::from_value(value.clone()).map_err(|error| {
1298                FlowError::InvalidArgument(format!("invalid OpenAI Chat modalities: {error}"))
1299            })?),
1300        };
1301        let n = super::optional_u64(obj, "n", "OpenAI Chat")?;
1302        let presence_penalty = super::optional_f64(obj, "presence_penalty", "OpenAI Chat")?;
1303        let prompt_cache_key = super::optional_string(obj, "prompt_cache_key", "OpenAI Chat")?;
1304        let prompt_cache_retention =
1305            super::optional_string(obj, "prompt_cache_retention", "OpenAI Chat")?;
1306        let reasoning_effort = super::optional_string(obj, "reasoning_effort", "OpenAI Chat")?;
1307        let safety_identifier = super::optional_string(obj, "safety_identifier", "OpenAI Chat")?;
1308        let seed = super::optional_i64(obj, "seed", "OpenAI Chat")?;
1309        let verbosity = super::optional_string(obj, "verbosity", "OpenAI Chat")?;
1310        let metadata = super::optional_object(obj, "metadata", "OpenAI Chat")?;
1311        let audio = super::optional_object(obj, "audio", "OpenAI Chat")?;
1312        let logit_bias = super::optional_object(obj, "logit_bias", "OpenAI Chat")?;
1313        let moderation = super::optional_object(obj, "moderation", "OpenAI Chat")?;
1314        let prediction = super::optional_object(obj, "prediction", "OpenAI Chat")?;
1315        let prompt_cache_options =
1316            super::optional_object(obj, "prompt_cache_options", "OpenAI Chat")?;
1317        let response_format = super::optional_object(obj, "response_format", "OpenAI Chat")?;
1318        let stream_options = super::optional_object(obj, "stream_options", "OpenAI Chat")?;
1319        let web_search_options = super::optional_object(obj, "web_search_options", "OpenAI Chat")?;
1320        let extra: serde_json::Map<String, Json> = obj
1321            .iter()
1322            .filter(|(k, _)| !MODELED_REQUEST_KEYS.contains(&k.as_str()))
1323            .map(|(k, v)| (k.clone(), v.clone()))
1324            .collect();
1325
1326        Ok(AnnotatedLlmRequest {
1327            messages,
1328            instructions: None,
1329            model,
1330            params,
1331            tools,
1332            tool_choice,
1333            store,
1334            previous_response_id: None,
1335            truncation: None,
1336            reasoning: None,
1337            include: None,
1338            user,
1339            metadata,
1340            service_tier,
1341            parallel_tool_calls,
1342            max_output_tokens: None,
1343            max_tool_calls: None,
1344            top_logprobs,
1345            stream,
1346            api_specific: Some(ApiSpecificRequest::OpenAIChat {
1347                audio,
1348                frequency_penalty,
1349                function_call: obj.get("function_call").cloned(),
1350                functions,
1351                logit_bias,
1352                logprobs,
1353                modalities,
1354                moderation,
1355                n,
1356                prediction,
1357                presence_penalty,
1358                prompt_cache_key,
1359                prompt_cache_options,
1360                prompt_cache_retention,
1361                reasoning_effort,
1362                response_format,
1363                safety_identifier,
1364                seed,
1365                stream_options,
1366                verbosity,
1367                web_search_options,
1368            }),
1369            extra,
1370        })
1371    }
1372
1373    fn encode(&self, annotated: &AnnotatedLlmRequest, original: &LlmRequest) -> Result<LlmRequest> {
1374        let baseline = self.decode(original)?;
1375        let mut content = original.content.clone();
1376        let obj = content
1377            .as_object_mut()
1378            .ok_or_else(|| FlowError::Internal("original content is not an object".into()))?;
1379        patch_chat_messages_and_validate(obj, annotated, &baseline)?;
1380        patch_chat_model_and_params(obj, annotated, &baseline);
1381        patch_chat_tools(obj, annotated, &baseline)?;
1382        patch_chat_common_fields(obj, annotated, &baseline);
1383        patch_chat_api_specific(obj, &annotated.api_specific, &baseline.api_specific)?;
1384        patch_extra_fields(obj, &baseline.extra, &annotated.extra);
1385
1386        Ok(LlmRequest {
1387            headers: original.headers.clone(),
1388            content,
1389        })
1390    }
1391}
1392
1393/// Helper to construct a [`Json`] number from an `f64`.
1394fn json_f64(v: f64) -> Json {
1395    serde_json::Number::from_f64(v)
1396        .map(Json::Number)
1397        .unwrap_or(Json::Null)
1398}
1399
1400// ---------------------------------------------------------------------------
1401// Streaming codec
1402// ---------------------------------------------------------------------------
1403
1404/// Streaming counterpart to [`OpenAIChatCodec`].
1405///
1406/// Replays the OpenAI Chat Completions SSE chunk sequence into the same JSON shape returned for a
1407/// non-streaming request (`{id, object, created, model, choices: [{message, finish_reason}],
1408/// usage}`). Once finalized, the assembled JSON can be fed back through
1409/// [`OpenAIChatCodec::decode_response`] to produce the canonical
1410/// [`AnnotatedLlmResponse`].
1411///
1412/// # Strategy
1413///
1414/// Chat Completions streams untyped SSE chunks of `{choices: [{index, delta: {...},
1415/// finish_reason: ...}]}`. Each delta may carry a `role` (typically only on the first chunk),
1416/// incremental `content` text, or partial `tool_calls` whose `function.arguments` stream as a
1417/// JSON-encoded string fragment-by-fragment. Top-level fields (`id`, `model`, `created`) are
1418/// repeated on every chunk; we capture them once. Final-chunk `usage` is preserved when emitted
1419/// (only sent when `stream_options.include_usage` is set on the request).
1420///
1421/// The OpenAI `[DONE]` end-of-stream sentinel is dropped by the SSE event decoder before
1422/// reaching the collector, so this codec never sees it.
1423///
1424/// Internal state lives behind `Arc<Mutex<...>>` so the `&self`-produced collector and finalizer
1425/// closures share access. Each instance is single-use because [`LlmFinalizerFn`] consumes the
1426/// finalize step.
1427///
1428/// [`LlmFinalizerFn`]: crate::api::runtime::LlmFinalizerFn
1429pub struct OpenAIChatStreamingCodec {
1430    state: std::sync::Arc<std::sync::Mutex<OpenAIChatStreamingState>>,
1431}
1432
1433impl OpenAIChatStreamingCodec {
1434    /// Creates a fresh streaming codec with empty accumulator state.
1435    pub fn new() -> Self {
1436        Self {
1437            state: std::sync::Arc::new(std::sync::Mutex::new(OpenAIChatStreamingState::default())),
1438        }
1439    }
1440}
1441
1442impl Default for OpenAIChatStreamingCodec {
1443    fn default() -> Self {
1444        Self::new()
1445    }
1446}
1447
1448impl super::streaming::StreamingCodec for OpenAIChatStreamingCodec {
1449    fn collector(&self) -> crate::api::runtime::LlmCollectorFn {
1450        let state = std::sync::Arc::clone(&self.state);
1451        Box::new(move |event: Json| -> Result<()> {
1452            let mut guard = state
1453                .lock()
1454                .unwrap_or_else(|poisoned| poisoned.into_inner());
1455            guard.observe(&event);
1456            Ok(())
1457        })
1458    }
1459
1460    fn finalizer(&self) -> crate::api::runtime::LlmFinalizerFn {
1461        let state = std::sync::Arc::clone(&self.state);
1462        Box::new(move || -> Json {
1463            let mut guard = state
1464                .lock()
1465                .unwrap_or_else(|poisoned| poisoned.into_inner());
1466            std::mem::take(&mut *guard).finalize()
1467        })
1468    }
1469}
1470
1471#[derive(Debug, Default)]
1472struct OpenAIChatStreamingState {
1473    id: Option<String>,
1474    object: Option<String>,
1475    created: Option<u64>,
1476    model: Option<String>,
1477    /// Per-choice accumulator keyed by `choice.index`. BTreeMap so finalize emits choices in
1478    /// stable order.
1479    choices: std::collections::BTreeMap<u64, ChoiceState>,
1480    /// Top-level usage from the final chunk (when `stream_options.include_usage` is set).
1481    usage: Option<Json>,
1482}
1483
1484#[derive(Debug, Default)]
1485struct ChoiceState {
1486    role: Option<String>,
1487    content: String,
1488    has_content: bool,
1489    /// Tool calls keyed by their `index` within the choice. Each tool call's `arguments` is
1490    /// streamed as a JSON-encoded string accumulated fragment-by-fragment.
1491    tool_calls: std::collections::BTreeMap<u64, ToolCallState>,
1492    finish_reason: Option<String>,
1493}
1494
1495#[derive(Debug, Default)]
1496struct ToolCallState {
1497    id: Option<String>,
1498    type_: Option<String>,
1499    name: Option<String>,
1500    arguments: String,
1501}
1502
1503impl OpenAIChatStreamingState {
1504    fn observe(&mut self, chunk: &Json) {
1505        // Top-level fields (id, object, created, model) are repeated on every chunk; capture once
1506        // each so unrelated later chunks can't overwrite the canonical values.
1507        if self.id.is_none()
1508            && let Some(id) = chunk.get("id").and_then(Json::as_str)
1509        {
1510            self.id = Some(id.to_string());
1511        }
1512        if self.object.is_none()
1513            && let Some(obj) = chunk.get("object").and_then(Json::as_str)
1514        {
1515            self.object = Some(obj.to_string());
1516        }
1517        if self.created.is_none()
1518            && let Some(c) = chunk.get("created").and_then(Json::as_u64)
1519        {
1520            self.created = Some(c);
1521        }
1522        if self.model.is_none()
1523            && let Some(m) = chunk.get("model").and_then(Json::as_str)
1524        {
1525            self.model = Some(m.to_string());
1526        }
1527        if let Some(usage) = chunk.get("usage") {
1528            // Some streams emit `usage: null` on every chunk and the real usage only on the
1529            // final chunk; only capture non-null usage objects.
1530            if !usage.is_null() {
1531                self.usage = Some(usage.clone());
1532            }
1533        }
1534        let Some(choices) = chunk.get("choices").and_then(Json::as_array) else {
1535            return;
1536        };
1537        for choice in choices {
1538            self.observe_choice(choice);
1539        }
1540    }
1541
1542    fn observe_choice(&mut self, choice: &Json) {
1543        let index = choice.get("index").and_then(Json::as_u64).unwrap_or(0);
1544        let entry = self.choices.entry(index).or_default();
1545        entry.observe_finish_reason(choice);
1546        entry.observe_delta(choice.get("delta"));
1547    }
1548
1549    fn finalize(self) -> Json {
1550        let mut output = serde_json::Map::new();
1551        if let Some(id) = self.id {
1552            output.insert("id".to_string(), Json::String(id));
1553        }
1554        // After streaming, the final shape is `chat.completion`, not `chat.completion.chunk`.
1555        // Strip the `.chunk` suffix so the assembled JSON round-trips through
1556        // OpenAIChatCodec::decode_response with the same `object` field a non-streaming response
1557        // would carry.
1558        if let Some(object) = self.object {
1559            let normalized = object
1560                .strip_suffix(".chunk")
1561                .map(str::to_string)
1562                .unwrap_or(object);
1563            output.insert("object".to_string(), Json::String(normalized));
1564        }
1565        if let Some(created) = self.created {
1566            output.insert("created".to_string(), Json::Number(created.into()));
1567        }
1568        if let Some(model) = self.model {
1569            output.insert("model".to_string(), Json::String(model));
1570        }
1571        let choices: Vec<Json> = self
1572            .choices
1573            .into_iter()
1574            .map(|(index, choice)| choice.finalize(index))
1575            .collect();
1576        output.insert("choices".to_string(), Json::Array(choices));
1577        if let Some(usage) = self.usage {
1578            output.insert("usage".to_string(), usage);
1579        }
1580        Json::Object(output)
1581    }
1582}
1583
1584impl ChoiceState {
1585    fn observe_finish_reason(&mut self, choice: &Json) {
1586        if let Some(reason) = choice.get("finish_reason").and_then(Json::as_str) {
1587            self.finish_reason = Some(reason.to_string());
1588        }
1589    }
1590
1591    fn observe_delta(&mut self, delta: Option<&Json>) {
1592        let Some(delta) = delta else {
1593            return;
1594        };
1595        if let Some(role) = delta.get("role").and_then(Json::as_str) {
1596            self.role = Some(role.to_string());
1597        }
1598        if let Some(content) = delta.get("content").and_then(Json::as_str) {
1599            self.content.push_str(content);
1600            self.has_content = true;
1601        }
1602        self.observe_tool_calls(delta);
1603    }
1604
1605    fn observe_tool_calls(&mut self, delta: &Json) {
1606        if let Some(tool_calls) = delta.get("tool_calls").and_then(Json::as_array) {
1607            for tool_call in tool_calls {
1608                self.observe_tool_call(tool_call);
1609            }
1610        }
1611    }
1612
1613    fn observe_tool_call(&mut self, tool_call: &Json) {
1614        let index = tool_call.get("index").and_then(Json::as_u64).unwrap_or(0);
1615        let state = self.tool_calls.entry(index).or_default();
1616        if let Some(id) = tool_call.get("id").and_then(Json::as_str) {
1617            state.id = Some(id.to_string());
1618        }
1619        if let Some(type_) = tool_call.get("type").and_then(Json::as_str) {
1620            state.type_ = Some(type_.to_string());
1621        }
1622        if let Some(function) = tool_call.get("function") {
1623            state.observe_function(function);
1624        }
1625    }
1626
1627    fn finalize(self, index: u64) -> Json {
1628        let mut message = serde_json::Map::new();
1629        message.insert(
1630            "role".to_string(),
1631            Json::String(self.role.unwrap_or_else(|| "assistant".to_string())),
1632        );
1633        // OpenAI's wire format uses `content: null` when the model only emitted tool calls.
1634        // Preserve that distinction: empty-string content when the model said something, null
1635        // when it didn't.
1636        if self.has_content {
1637            message.insert("content".to_string(), Json::String(self.content));
1638        } else {
1639            message.insert("content".to_string(), Json::Null);
1640        }
1641        if !self.tool_calls.is_empty() {
1642            let tool_calls: Vec<Json> = self
1643                .tool_calls
1644                .into_values()
1645                .map(ToolCallState::finalize)
1646                .collect();
1647            message.insert("tool_calls".to_string(), Json::Array(tool_calls));
1648        }
1649        let mut choice = serde_json::Map::new();
1650        choice.insert("index".to_string(), Json::Number(index.into()));
1651        choice.insert("message".to_string(), Json::Object(message));
1652        if let Some(reason) = self.finish_reason {
1653            choice.insert("finish_reason".to_string(), Json::String(reason));
1654        } else {
1655            choice.insert("finish_reason".to_string(), Json::Null);
1656        }
1657        Json::Object(choice)
1658    }
1659}
1660
1661impl ToolCallState {
1662    fn observe_function(&mut self, function: &Json) {
1663        if let Some(name) = function.get("name").and_then(Json::as_str) {
1664            self.name = Some(name.to_string());
1665        }
1666        if let Some(args) = function.get("arguments").and_then(Json::as_str) {
1667            self.arguments.push_str(args);
1668        }
1669    }
1670
1671    fn finalize(self) -> Json {
1672        let mut function = serde_json::Map::new();
1673        function.insert(
1674            "name".to_string(),
1675            Json::String(self.name.unwrap_or_default()),
1676        );
1677        function.insert("arguments".to_string(), Json::String(self.arguments));
1678        let mut call = serde_json::Map::new();
1679        if let Some(id) = self.id {
1680            call.insert("id".to_string(), Json::String(id));
1681        }
1682        call.insert(
1683            "type".to_string(),
1684            Json::String(self.type_.unwrap_or_else(|| "function".to_string())),
1685        );
1686        call.insert("function".to_string(), Json::Object(function));
1687        Json::Object(call)
1688    }
1689}
1690
1691// ---------------------------------------------------------------------------
1692// Tests
1693// ---------------------------------------------------------------------------
1694
1695#[cfg(test)]
1696#[path = "../../tests/unit/codec/openai_chat_tests.rs"]
1697mod tests;