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::error::{FlowError, Result};
13use crate::json::Json;
14
15use super::request::{AnnotatedLlmRequest, GenerationParams, Message, ToolChoice, ToolDefinition};
16use super::response::{
17    AnnotatedLlmResponse, ApiSpecificResponse, FinishReason, RawUsageCost, ResponseToolCall, Usage,
18    estimate_cost_for_provider, infer_model_provider, provider_reported_cost,
19};
20use super::traits::{LlmCodec, LlmResponseCodec};
21
22// ---------------------------------------------------------------------------
23// Public codec struct
24// ---------------------------------------------------------------------------
25
26/// Built-in codec for the OpenAI Chat Completions API.
27pub struct OpenAIChatCodec;
28
29// ---------------------------------------------------------------------------
30// Private intermediate serde structs for response decode
31// ---------------------------------------------------------------------------
32
33#[derive(Deserialize)]
34struct RawChatCompletion {
35    id: Option<String>,
36    model: Option<String>,
37    choices: Option<Vec<RawChoice>>,
38    usage: Option<RawChatUsage>,
39    system_fingerprint: Option<String>,
40    service_tier: Option<String>,
41    #[serde(flatten)]
42    extra: serde_json::Map<String, Json>,
43}
44
45#[derive(Deserialize)]
46struct RawChoice {
47    message: Option<RawMessage>,
48    finish_reason: Option<String>,
49    logprobs: Option<Json>,
50}
51
52#[derive(Deserialize)]
53struct RawMessage {
54    content: Option<String>,
55    tool_calls: Option<Vec<RawToolCall>>,
56}
57
58#[derive(Deserialize)]
59struct RawToolCall {
60    id: Option<String>,
61    function: Option<RawFunction>,
62}
63
64#[derive(Deserialize)]
65struct RawFunction {
66    name: Option<String>,
67    arguments: Option<String>,
68}
69
70#[derive(Deserialize)]
71struct RawChatUsage {
72    prompt_tokens: Option<u64>,
73    completion_tokens: Option<u64>,
74    total_tokens: Option<u64>,
75    prompt_tokens_details: Option<RawPromptTokensDetails>,
76    #[serde(rename = "cost_usd")]
77    provider_cost: Option<f64>,
78    cost: Option<RawUsageCost>,
79}
80
81#[derive(Deserialize)]
82struct RawPromptTokensDetails {
83    cached_tokens: Option<u64>,
84}
85
86// ---------------------------------------------------------------------------
87// Helper functions
88// ---------------------------------------------------------------------------
89
90/// Map OpenAI Chat finish_reason string to normalized [`FinishReason`].
91fn map_chat_finish_reason(reason: &str) -> FinishReason {
92    match reason {
93        "stop" => FinishReason::Complete,
94        "length" => FinishReason::Length,
95        "tool_calls" | "function_call" => FinishReason::ToolUse,
96        "content_filter" => FinishReason::ContentFilter,
97        other => FinishReason::Unknown(other.to_string()),
98    }
99}
100
101/// Parse OpenAI tool call arguments from JSON string to [`Json`] value.
102///
103/// Falls back to [`Json::String`] if parsing fails (malformed model output).
104fn parse_arguments(arguments: &str) -> Json {
105    serde_json::from_str(arguments).unwrap_or_else(|_| Json::String(arguments.to_string()))
106}
107
108/// Keys that are modeled in [`AnnotatedLlmRequest`] and should NOT go into `extra`.
109const MODELED_REQUEST_KEYS: &[&str] = &[
110    "messages",
111    "model",
112    "temperature",
113    "max_tokens",
114    "max_completion_tokens",
115    "top_p",
116    "stop",
117    "tools",
118    "tool_choice",
119    "store",
120    "user",
121    "metadata",
122    "service_tier",
123    "parallel_tool_calls",
124    "top_logprobs",
125    "stream",
126];
127
128// ---------------------------------------------------------------------------
129// LlmResponseCodec implementation
130// ---------------------------------------------------------------------------
131
132impl LlmResponseCodec for OpenAIChatCodec {
133    fn decode_response(&self, response: &Json) -> Result<AnnotatedLlmResponse> {
134        let raw: RawChatCompletion = serde_json::from_value(response.clone())
135            .map_err(|e| FlowError::Internal(format!("OpenAI Chat response decode: {e}")))?;
136
137        // Extract first choice (if any).
138        let choice = raw.choices.as_ref().and_then(|c| c.first());
139
140        // Map message content.
141        let message = choice
142            .and_then(|c| c.message.as_ref())
143            .and_then(|m| m.content.as_ref())
144            .map(|s| super::request::MessageContent::Text(s.clone()));
145
146        // Map tool calls, skipping entries that lack a usable function body.
147        // Some providers (proxies, vLLM, NIM) may return partial tool_calls
148        // entries where `function` or `function.name` is absent or null.
149        let tool_calls = choice
150            .and_then(|c| c.message.as_ref())
151            .and_then(|m| m.tool_calls.as_ref())
152            .map(|tcs| {
153                tcs.iter()
154                    .filter_map(|tc| {
155                        let func = tc.function.as_ref()?;
156                        let name = func.name.as_ref()?;
157                        Some(ResponseToolCall {
158                            id: tc.id.clone().unwrap_or_default(),
159                            name: name.clone(),
160                            arguments: func
161                                .arguments
162                                .as_deref()
163                                .map(parse_arguments)
164                                .unwrap_or(Json::Object(Default::default())),
165                        })
166                    })
167                    .collect::<Vec<_>>()
168            });
169
170        // Map finish reason.
171        let finish_reason = choice
172            .and_then(|c| c.finish_reason.as_deref())
173            .map(map_chat_finish_reason);
174
175        // Map usage.
176        let model_for_pricing = raw.model.as_deref();
177        let model_provider = infer_model_provider("openai", model_for_pricing);
178        let usage = raw.usage.map(|u| {
179            let mut usage = Usage {
180                prompt_tokens: u.prompt_tokens,
181                completion_tokens: u.completion_tokens,
182                total_tokens: u.total_tokens,
183                cache_read_tokens: u.prompt_tokens_details.and_then(|d| d.cached_tokens),
184                cache_write_tokens: None,
185                cost: provider_reported_cost(u.provider_cost, u.cost),
186            };
187            if usage.cost.is_none() {
188                usage.cost = model_for_pricing.and_then(|model| {
189                    estimate_cost_for_provider(model_provider.as_deref(), model, &usage)
190                });
191            }
192            usage
193        });
194
195        // Build API-specific fields.
196        let logprobs = choice.and_then(|c| c.logprobs.clone());
197        let api_specific = Some(ApiSpecificResponse::OpenAIChat {
198            logprobs,
199            system_fingerprint: raw.system_fingerprint,
200            service_tier: raw.service_tier,
201        });
202
203        Ok(AnnotatedLlmResponse {
204            id: raw.id,
205            model: raw.model,
206            message,
207            tool_calls,
208            finish_reason,
209            usage,
210            api_specific,
211            extra: raw.extra,
212        })
213    }
214}
215
216// ---------------------------------------------------------------------------
217// LlmCodec implementation
218// ---------------------------------------------------------------------------
219
220impl LlmCodec for OpenAIChatCodec {
221    fn decode(&self, request: &LlmRequest) -> Result<AnnotatedLlmRequest> {
222        let obj = request
223            .content
224            .as_object()
225            .ok_or_else(|| FlowError::Internal("request content is not an object".into()))?;
226
227        // Extract messages (default to empty vec if absent).
228        let messages: Vec<Message> = obj
229            .get("messages")
230            .map(|v| serde_json::from_value(v.clone()).unwrap_or_default())
231            .unwrap_or_default();
232
233        // Extract model.
234        let model = obj.get("model").and_then(|v| v.as_str()).map(String::from);
235
236        // Extract generation params.
237        let temperature = obj.get("temperature").and_then(|v| v.as_f64());
238        let top_p = obj.get("top_p").and_then(|v| v.as_f64());
239        let stop = obj
240            .get("stop")
241            .and_then(|v| serde_json::from_value::<Vec<String>>(v.clone()).ok());
242
243        // max_completion_tokens takes priority over max_tokens (newer API key).
244        let max_tokens = obj
245            .get("max_completion_tokens")
246            .and_then(|v| v.as_u64())
247            .or_else(|| obj.get("max_tokens").and_then(|v| v.as_u64()));
248
249        let params =
250            if temperature.is_some() || max_tokens.is_some() || top_p.is_some() || stop.is_some() {
251                Some(GenerationParams {
252                    temperature,
253                    max_tokens,
254                    top_p,
255                    stop,
256                })
257            } else {
258                None
259            };
260
261        // Extract tools.
262        let tools: Option<Vec<ToolDefinition>> = obj
263            .get("tools")
264            .map(|v| serde_json::from_value(v.clone()))
265            .transpose()
266            .map_err(|e| FlowError::Internal(format!("OpenAI Chat tools decode: {e}")))?;
267
268        // Extract tool_choice.
269        let tool_choice: Option<ToolChoice> = obj
270            .get("tool_choice")
271            .map(|v| serde_json::from_value(v.clone()))
272            .transpose()
273            .map_err(|e| FlowError::Internal(format!("OpenAI Chat tool_choice decode: {e}")))?;
274
275        // Collect extra fields (keys not in MODELED_REQUEST_KEYS).
276        let extra: serde_json::Map<String, Json> = obj
277            .iter()
278            .filter(|(k, _)| !MODELED_REQUEST_KEYS.contains(&k.as_str()))
279            .map(|(k, v)| (k.clone(), v.clone()))
280            .collect();
281
282        Ok(AnnotatedLlmRequest {
283            messages,
284            model,
285            params,
286            tools,
287            tool_choice,
288            store: obj.get("store").and_then(|v| v.as_bool()),
289            previous_response_id: None,
290            truncation: None,
291            reasoning: None,
292            include: None,
293            user: obj.get("user").and_then(|v| v.as_str()).map(String::from),
294            metadata: obj.get("metadata").cloned(),
295            service_tier: obj
296                .get("service_tier")
297                .and_then(|v| v.as_str())
298                .map(String::from),
299            parallel_tool_calls: obj.get("parallel_tool_calls").and_then(|v| v.as_bool()),
300            max_output_tokens: None,
301            max_tool_calls: None,
302            top_logprobs: obj.get("top_logprobs").and_then(|v| v.as_u64()),
303            stream: obj.get("stream").and_then(|v| v.as_bool()),
304            extra,
305        })
306    }
307
308    fn encode(&self, annotated: &AnnotatedLlmRequest, original: &LlmRequest) -> Result<LlmRequest> {
309        let mut content = original.content.clone();
310        let obj = content
311            .as_object_mut()
312            .ok_or_else(|| FlowError::Internal("original content is not an object".into()))?;
313
314        insert_serialized(obj, "messages", &annotated.messages, "messages")?;
315
316        if let Some(ref model) = annotated.model {
317            obj.insert("model".into(), Json::String(model.clone()));
318        }
319
320        if let Some(ref params) = annotated.params {
321            overlay_generation_params(obj, params)?;
322        }
323
324        if let Some(ref tools) = annotated.tools {
325            insert_serialized(obj, "tools", tools, "tools")?;
326        }
327
328        if let Some(ref tool_choice) = annotated.tool_choice {
329            insert_serialized(obj, "tool_choice", tool_choice, "tool_choice")?;
330        }
331
332        if let Some(store) = annotated.store {
333            obj.insert("store".into(), Json::Bool(store));
334        }
335        if let Some(ref user) = annotated.user {
336            obj.insert("user".into(), Json::String(user.clone()));
337        }
338        if let Some(ref metadata) = annotated.metadata {
339            obj.insert("metadata".into(), metadata.clone());
340        }
341        if let Some(ref service_tier) = annotated.service_tier {
342            obj.insert("service_tier".into(), Json::String(service_tier.clone()));
343        }
344        if let Some(parallel_tool_calls) = annotated.parallel_tool_calls {
345            obj.insert(
346                "parallel_tool_calls".into(),
347                Json::Bool(parallel_tool_calls),
348            );
349        }
350        if let Some(top_logprobs) = annotated.top_logprobs {
351            obj.insert("top_logprobs".into(), Json::from(top_logprobs));
352        }
353        if let Some(stream) = annotated.stream {
354            obj.insert("stream".into(), Json::Bool(stream));
355        }
356
357        for (k, v) in &annotated.extra {
358            obj.insert(k.clone(), v.clone());
359        }
360
361        // Force `stream_options.include_usage` when the caller did not set it.
362        //
363        // Rationale: OpenAI-compatible backends only emit the terminal chunk
364        // containing `usage` (prompt/completion/total tokens) when this flag
365        // is true. Without it, Phoenix spans show `token_count=0` for every
366        // LLM call even though the provider knows the real counts. The
367        // observability exporter (OpenInference) reads usage off the
368        // annotated response, so the flag has to be set at the request level
369        // before bytes go on the wire.
370        //
371        // Guarded on `stream == true` per the OpenAI Chat Completions spec,
372        // which restricts `stream_options` to streaming requests. Caller-
373        // provided `stream_options` are preserved verbatim (including
374        // explicit opt-outs such as `include_usage: false`).
375        let is_streaming = obj.get("stream").and_then(|v| v.as_bool()).unwrap_or(false);
376        if is_streaming && !obj.contains_key("stream_options") {
377            obj.insert(
378                "stream_options".into(),
379                serde_json::json!({"include_usage": true}),
380            );
381        }
382
383        Ok(LlmRequest {
384            headers: original.headers.clone(),
385            content,
386        })
387    }
388}
389
390/// Helper to construct a [`Json`] number from an `f64`.
391fn json_f64(v: f64) -> Json {
392    serde_json::Number::from_f64(v)
393        .map(Json::Number)
394        .unwrap_or(Json::Null)
395}
396
397fn insert_serialized<T: serde::Serialize>(
398    obj: &mut serde_json::Map<String, Json>,
399    key: &str,
400    value: &T,
401    context: &str,
402) -> Result<()> {
403    let json = serde_json::to_value(value)
404        .map_err(|e| FlowError::Internal(format!("OpenAI Chat {context} encode: {e}")))?;
405    obj.insert(key.into(), json);
406    Ok(())
407}
408
409fn overlay_generation_params(
410    obj: &mut serde_json::Map<String, Json>,
411    params: &GenerationParams,
412) -> Result<()> {
413    if let Some(temp) = params.temperature {
414        obj.insert("temperature".into(), json_f64(temp));
415    }
416    if let Some(top_p) = params.top_p {
417        obj.insert("top_p".into(), json_f64(top_p));
418    }
419    if let Some(ref stop) = params.stop {
420        insert_serialized(obj, "stop", stop, "stop")?;
421    }
422    if let Some(max_tokens) = params.max_tokens {
423        let key = if obj.contains_key("max_completion_tokens") {
424            "max_completion_tokens"
425        } else {
426            "max_tokens"
427        };
428        obj.insert(key.into(), Json::from(max_tokens));
429    }
430    Ok(())
431}
432
433// ---------------------------------------------------------------------------
434// Streaming codec
435// ---------------------------------------------------------------------------
436
437/// Streaming counterpart to [`OpenAIChatCodec`].
438///
439/// Replays the OpenAI Chat Completions SSE chunk sequence into the same JSON shape returned for a
440/// non-streaming request (`{id, object, created, model, choices: [{message, finish_reason}],
441/// usage}`). Once finalized, the assembled JSON can be fed back through
442/// [`OpenAIChatCodec::decode_response`] to produce the canonical
443/// [`AnnotatedLlmResponse`].
444///
445/// # Strategy
446///
447/// Chat Completions streams untyped SSE chunks of `{choices: [{index, delta: {...},
448/// finish_reason: ...}]}`. Each delta may carry a `role` (typically only on the first chunk),
449/// incremental `content` text, or partial `tool_calls` whose `function.arguments` stream as a
450/// JSON-encoded string fragment-by-fragment. Top-level fields (`id`, `model`, `created`) are
451/// repeated on every chunk; we capture them once. Final-chunk `usage` is preserved when emitted
452/// (only sent when `stream_options.include_usage` is set on the request).
453///
454/// The OpenAI `[DONE]` end-of-stream sentinel is dropped by the SSE event decoder before
455/// reaching the collector, so this codec never sees it.
456///
457/// Internal state lives behind `Arc<Mutex<...>>` so the `&self`-produced collector and finalizer
458/// closures share access. Each instance is single-use because [`LlmFinalizerFn`] consumes the
459/// finalize step.
460///
461/// [`LlmFinalizerFn`]: crate::api::runtime::LlmFinalizerFn
462pub struct OpenAIChatStreamingCodec {
463    state: std::sync::Arc<std::sync::Mutex<OpenAIChatStreamingState>>,
464}
465
466impl OpenAIChatStreamingCodec {
467    /// Creates a fresh streaming codec with empty accumulator state.
468    pub fn new() -> Self {
469        Self {
470            state: std::sync::Arc::new(std::sync::Mutex::new(OpenAIChatStreamingState::default())),
471        }
472    }
473}
474
475impl Default for OpenAIChatStreamingCodec {
476    fn default() -> Self {
477        Self::new()
478    }
479}
480
481impl super::streaming::StreamingCodec for OpenAIChatStreamingCodec {
482    fn collector(&self) -> crate::api::runtime::LlmCollectorFn {
483        let state = std::sync::Arc::clone(&self.state);
484        Box::new(move |event: Json| -> Result<()> {
485            let mut guard = state
486                .lock()
487                .unwrap_or_else(|poisoned| poisoned.into_inner());
488            guard.observe(&event);
489            Ok(())
490        })
491    }
492
493    fn finalizer(&self) -> crate::api::runtime::LlmFinalizerFn {
494        let state = std::sync::Arc::clone(&self.state);
495        Box::new(move || -> Json {
496            let mut guard = state
497                .lock()
498                .unwrap_or_else(|poisoned| poisoned.into_inner());
499            std::mem::take(&mut *guard).finalize()
500        })
501    }
502}
503
504#[derive(Debug, Default)]
505struct OpenAIChatStreamingState {
506    id: Option<String>,
507    object: Option<String>,
508    created: Option<u64>,
509    model: Option<String>,
510    /// Per-choice accumulator keyed by `choice.index`. BTreeMap so finalize emits choices in
511    /// stable order.
512    choices: std::collections::BTreeMap<u64, ChoiceState>,
513    /// Top-level usage from the final chunk (when `stream_options.include_usage` is set).
514    usage: Option<Json>,
515}
516
517#[derive(Debug, Default)]
518struct ChoiceState {
519    role: Option<String>,
520    content: String,
521    has_content: bool,
522    /// Tool calls keyed by their `index` within the choice. Each tool call's `arguments` is
523    /// streamed as a JSON-encoded string accumulated fragment-by-fragment.
524    tool_calls: std::collections::BTreeMap<u64, ToolCallState>,
525    finish_reason: Option<String>,
526}
527
528#[derive(Debug, Default)]
529struct ToolCallState {
530    id: Option<String>,
531    type_: Option<String>,
532    name: Option<String>,
533    arguments: String,
534}
535
536impl OpenAIChatStreamingState {
537    fn observe(&mut self, chunk: &Json) {
538        // Top-level fields (id, object, created, model) are repeated on every chunk; capture once
539        // each so unrelated later chunks can't overwrite the canonical values.
540        if self.id.is_none()
541            && let Some(id) = chunk.get("id").and_then(Json::as_str)
542        {
543            self.id = Some(id.to_string());
544        }
545        if self.object.is_none()
546            && let Some(obj) = chunk.get("object").and_then(Json::as_str)
547        {
548            self.object = Some(obj.to_string());
549        }
550        if self.created.is_none()
551            && let Some(c) = chunk.get("created").and_then(Json::as_u64)
552        {
553            self.created = Some(c);
554        }
555        if self.model.is_none()
556            && let Some(m) = chunk.get("model").and_then(Json::as_str)
557        {
558            self.model = Some(m.to_string());
559        }
560        if let Some(usage) = chunk.get("usage") {
561            // Some streams emit `usage: null` on every chunk and the real usage only on the
562            // final chunk; only capture non-null usage objects.
563            if !usage.is_null() {
564                self.usage = Some(usage.clone());
565            }
566        }
567        let Some(choices) = chunk.get("choices").and_then(Json::as_array) else {
568            return;
569        };
570        for choice in choices {
571            self.observe_choice(choice);
572        }
573    }
574
575    fn observe_choice(&mut self, choice: &Json) {
576        let index = choice.get("index").and_then(Json::as_u64).unwrap_or(0);
577        let entry = self.choices.entry(index).or_default();
578        entry.observe_finish_reason(choice);
579        entry.observe_delta(choice.get("delta"));
580    }
581
582    fn finalize(self) -> Json {
583        let mut output = serde_json::Map::new();
584        if let Some(id) = self.id {
585            output.insert("id".to_string(), Json::String(id));
586        }
587        // After streaming, the final shape is `chat.completion`, not `chat.completion.chunk`.
588        // Strip the `.chunk` suffix so the assembled JSON round-trips through
589        // OpenAIChatCodec::decode_response with the same `object` field a non-streaming response
590        // would carry.
591        if let Some(object) = self.object {
592            let normalized = object
593                .strip_suffix(".chunk")
594                .map(str::to_string)
595                .unwrap_or(object);
596            output.insert("object".to_string(), Json::String(normalized));
597        }
598        if let Some(created) = self.created {
599            output.insert("created".to_string(), Json::Number(created.into()));
600        }
601        if let Some(model) = self.model {
602            output.insert("model".to_string(), Json::String(model));
603        }
604        let choices: Vec<Json> = self
605            .choices
606            .into_iter()
607            .map(|(index, choice)| choice.finalize(index))
608            .collect();
609        output.insert("choices".to_string(), Json::Array(choices));
610        if let Some(usage) = self.usage {
611            output.insert("usage".to_string(), usage);
612        }
613        Json::Object(output)
614    }
615}
616
617impl ChoiceState {
618    fn observe_finish_reason(&mut self, choice: &Json) {
619        if let Some(reason) = choice.get("finish_reason").and_then(Json::as_str) {
620            self.finish_reason = Some(reason.to_string());
621        }
622    }
623
624    fn observe_delta(&mut self, delta: Option<&Json>) {
625        let Some(delta) = delta else {
626            return;
627        };
628        if let Some(role) = delta.get("role").and_then(Json::as_str) {
629            self.role = Some(role.to_string());
630        }
631        if let Some(content) = delta.get("content").and_then(Json::as_str) {
632            self.content.push_str(content);
633            self.has_content = true;
634        }
635        self.observe_tool_calls(delta);
636    }
637
638    fn observe_tool_calls(&mut self, delta: &Json) {
639        if let Some(tool_calls) = delta.get("tool_calls").and_then(Json::as_array) {
640            for tool_call in tool_calls {
641                self.observe_tool_call(tool_call);
642            }
643        }
644    }
645
646    fn observe_tool_call(&mut self, tool_call: &Json) {
647        let index = tool_call.get("index").and_then(Json::as_u64).unwrap_or(0);
648        let state = self.tool_calls.entry(index).or_default();
649        if let Some(id) = tool_call.get("id").and_then(Json::as_str) {
650            state.id = Some(id.to_string());
651        }
652        if let Some(type_) = tool_call.get("type").and_then(Json::as_str) {
653            state.type_ = Some(type_.to_string());
654        }
655        if let Some(function) = tool_call.get("function") {
656            state.observe_function(function);
657        }
658    }
659
660    fn finalize(self, index: u64) -> Json {
661        let mut message = serde_json::Map::new();
662        message.insert(
663            "role".to_string(),
664            Json::String(self.role.unwrap_or_else(|| "assistant".to_string())),
665        );
666        // OpenAI's wire format uses `content: null` when the model only emitted tool calls.
667        // Preserve that distinction: empty-string content when the model said something, null
668        // when it didn't.
669        if self.has_content {
670            message.insert("content".to_string(), Json::String(self.content));
671        } else {
672            message.insert("content".to_string(), Json::Null);
673        }
674        if !self.tool_calls.is_empty() {
675            let tool_calls: Vec<Json> = self
676                .tool_calls
677                .into_values()
678                .map(ToolCallState::finalize)
679                .collect();
680            message.insert("tool_calls".to_string(), Json::Array(tool_calls));
681        }
682        let mut choice = serde_json::Map::new();
683        choice.insert("index".to_string(), Json::Number(index.into()));
684        choice.insert("message".to_string(), Json::Object(message));
685        if let Some(reason) = self.finish_reason {
686            choice.insert("finish_reason".to_string(), Json::String(reason));
687        } else {
688            choice.insert("finish_reason".to_string(), Json::Null);
689        }
690        Json::Object(choice)
691    }
692}
693
694impl ToolCallState {
695    fn observe_function(&mut self, function: &Json) {
696        if let Some(name) = function.get("name").and_then(Json::as_str) {
697            self.name = Some(name.to_string());
698        }
699        if let Some(args) = function.get("arguments").and_then(Json::as_str) {
700            self.arguments.push_str(args);
701        }
702    }
703
704    fn finalize(self) -> Json {
705        let mut function = serde_json::Map::new();
706        function.insert(
707            "name".to_string(),
708            Json::String(self.name.unwrap_or_default()),
709        );
710        function.insert("arguments".to_string(), Json::String(self.arguments));
711        let mut call = serde_json::Map::new();
712        if let Some(id) = self.id {
713            call.insert("id".to_string(), Json::String(id));
714        }
715        call.insert(
716            "type".to_string(),
717            Json::String(self.type_.unwrap_or_else(|| "function".to_string())),
718        );
719        call.insert("function".to_string(), Json::Object(function));
720        Json::Object(call)
721    }
722}
723
724// ---------------------------------------------------------------------------
725// Tests
726// ---------------------------------------------------------------------------
727
728#[cfg(test)]
729#[path = "../../tests/unit/codec/openai_chat_tests.rs"]
730mod tests;