Skip to main content

switchyard_translation/codecs/openai_chat/
stream.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Streaming codec for OpenAI Chat Completions chunks.
5
6use serde_json::{Map, Value, json};
7
8use super::buffered::{decode_openai_usage, encode_openai_usage};
9use crate::LlmResponseChunk;
10use crate::codecs::common::{first_nonempty_string, reasoning_text_from_details};
11use crate::codecs::stream::{
12    StreamCodec, StreamTranslationState, record_source_identity, state_source_is, string_field,
13    target_model_or_source_model,
14};
15use crate::format::{FormatId, WireFormat};
16
17/// Stream codec for OpenAI Chat Completions chunks.
18pub struct OpenAiChatStreamCodec;
19
20impl StreamCodec for OpenAiChatStreamCodec {
21    fn format(&self) -> FormatId {
22        WireFormat::OpenAiChat.into()
23    }
24
25    fn is_terminal_event(&self, event: &Value) -> bool {
26        event
27            .get("choices")
28            .and_then(Value::as_array)
29            .into_iter()
30            .flatten()
31            .any(|choice| {
32                choice
33                    .get("finish_reason")
34                    .and_then(Value::as_str)
35                    .is_some()
36            })
37    }
38
39    fn sse_done_is_terminal(&self) -> bool {
40        true
41    }
42
43    fn stamp_streamed_response_model(&self, event: &mut Value, served_model: &str) {
44        if let Some(event) = event.as_object_mut() {
45            event.insert("model".to_string(), Value::String(served_model.to_string()));
46        }
47    }
48
49    fn decode_event(
50        &self,
51        state: &mut StreamTranslationState,
52        event: &Value,
53    ) -> Vec<LlmResponseChunk> {
54        decode_openai_chat_stream(state, event)
55    }
56
57    fn encode_event(
58        &self,
59        state: &mut StreamTranslationState,
60        event: LlmResponseChunk,
61    ) -> Vec<Value> {
62        encode_openai_chat_stream(state, event)
63    }
64
65    fn finish(&self, state: &mut StreamTranslationState) -> Vec<Value> {
66        finish_openai_chat_stream(state)
67    }
68}
69
70// Decodes one OpenAI Chat chunk into neutral streaming events.
71fn decode_openai_chat_stream(
72    state: &mut StreamTranslationState,
73    event: &Value,
74) -> Vec<LlmResponseChunk> {
75    let Some(object) = event.as_object() else {
76        return vec![LlmResponseChunk::DecodeError {
77            message: "OpenAI stream event is not an object".to_string(),
78        }];
79    };
80
81    // Upstream error frames carry no choices, so without this they would decode to a bare
82    // `MessageStart` and the error text would be dropped.
83    if let Some(error) = object.get("error") {
84        return vec![LlmResponseChunk::StreamError {
85            message: error
86                .get("message")
87                .and_then(Value::as_str)
88                .unwrap_or("unknown OpenAI stream error")
89                .to_string(),
90        }];
91    }
92
93    let mut out = Vec::new();
94    let mut identity_changed = false;
95    if state.model.is_none() {
96        state.model = string_field(object, "model");
97        identity_changed |= state.model.is_some();
98    }
99    if state.message_id.is_none() {
100        state.message_id = string_field(object, "id");
101        identity_changed |= state.message_id.is_some();
102    }
103    if !state.saw_message_start || identity_changed {
104        state.saw_message_start = true;
105        out.push(LlmResponseChunk::MessageStart {
106            id: state.message_id.clone(),
107            model: state.model.clone(),
108        });
109    }
110
111    if let Some(usage) = object.get("usage").filter(|usage| usage.is_object()) {
112        let usage = decode_openai_usage(Some(usage));
113        state.usage = usage.clone();
114        state.saw_backend_usage = true;
115        out.push(LlmResponseChunk::Usage(usage));
116    }
117
118    for choice in object
119        .get("choices")
120        .and_then(Value::as_array)
121        .into_iter()
122        .flatten()
123    {
124        let Some(choice) = choice.as_object() else {
125            continue;
126        };
127        if let Some(delta) = choice.get("delta").and_then(Value::as_object) {
128            if let Some(details) = delta
129                .get("reasoning_details")
130                .and_then(Value::as_array)
131                .filter(|details| !details.is_empty())
132            {
133                let text = reasoning_text_from_details(details)
134                    .or_else(|| {
135                        first_nonempty_string(delta, &["reasoning_content", "reasoning"])
136                            .map(ToOwned::to_owned)
137                    })
138                    .unwrap_or_default();
139                out.push(LlmResponseChunk::ReasoningDetailsDelta {
140                    index: 0,
141                    details: details.clone(),
142                    text,
143                });
144            } else {
145                for reasoning_key in ["reasoning_content", "reasoning"] {
146                    if let Some(text) = delta.get(reasoning_key).and_then(Value::as_str)
147                        && !text.is_empty()
148                    {
149                        out.push(LlmResponseChunk::ReasoningDelta {
150                            index: 0,
151                            text: text.to_string(),
152                        });
153                    }
154                }
155            }
156            if let Some(text) = delta.get("content").and_then(Value::as_str)
157                && !text.is_empty()
158            {
159                out.push(LlmResponseChunk::TextDelta {
160                    index: 0,
161                    text: text.to_string(),
162                });
163            }
164            if let Some(text) = delta.get("refusal").and_then(Value::as_str)
165                && !text.is_empty()
166            {
167                // Refusal deltas mark the whole message as a content-filter stop. This state
168                // persists across chunks so a later `finish_reason: "stop"` cannot overwrite it.
169                state.stop_reason = Some("content_filter".to_string());
170                out.push(LlmResponseChunk::TextDelta {
171                    index: 0,
172                    text: text.to_string(),
173                });
174            }
175            if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) {
176                for tool_call in tool_calls {
177                    if let Some(tool_call) = tool_call.as_object() {
178                        let function = tool_call.get("function").and_then(Value::as_object);
179                        let index =
180                            tool_call.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
181                        if let Some(name) = function
182                            .and_then(|function| function.get("name"))
183                            .and_then(Value::as_str)
184                            .filter(|name| !name.is_empty())
185                        {
186                            state
187                                .pending_chat_tool_names
188                                .entry(index)
189                                .or_default()
190                                .push_str(name);
191                        }
192                        let arguments_delta = function
193                            .and_then(|function| function.get("arguments"))
194                            .and_then(Value::as_str)
195                            .map(ToOwned::to_owned);
196                        out.push(LlmResponseChunk::ToolCallDelta {
197                            index,
198                            id: tool_call
199                                .get("id")
200                                .and_then(Value::as_str)
201                                .map(ToOwned::to_owned),
202                            // Names may continue after arguments begin; emit them at finish_reason.
203                            name: None,
204                            arguments_delta,
205                        });
206                    }
207                }
208            }
209        }
210        if let Some(reason) = choice.get("finish_reason").and_then(Value::as_str) {
211            for (index, name) in std::mem::take(&mut state.pending_chat_tool_names) {
212                out.push(LlmResponseChunk::ToolCallDelta {
213                    index,
214                    id: None,
215                    name: Some(name),
216                    arguments_delta: None,
217                });
218            }
219            let reason =
220                if reason == "stop" && state.stop_reason.as_deref() == Some("content_filter") {
221                    "content_filter"
222                } else {
223                    reason
224                };
225            out.push(LlmResponseChunk::MessageStop {
226                reason: Some(reason.to_string()),
227            });
228        }
229    }
230    out
231}
232
233// Encodes neutral streaming events into OpenAI Chat chunks.
234fn encode_openai_chat_stream(
235    state: &mut StreamTranslationState,
236    event: LlmResponseChunk,
237) -> Vec<Value> {
238    // An in-band error is terminal: once the error is emitted, drop every later chunk.
239    if state.errored {
240        return Vec::new();
241    }
242    match event {
243        LlmResponseChunk::MessageStart { id, model } => {
244            record_source_identity(state, id, model);
245            // `finish_openai_chat_stream` uses this flag to decide whether a clean EOF needs a
246            // synthesized terminal chunk. Set it on the encode path as well as the decode path.
247            state.saw_message_start = true;
248            if state.emitted_message_start
249                || (!state_source_is(state, WireFormat::AnthropicMessages)
250                    && !state_source_is(state, WireFormat::OpenAiResponses))
251            {
252                Vec::new()
253            } else {
254                state.emitted_message_start = true;
255                vec![openai_stream_chunk(
256                    state,
257                    json!({"role": "assistant"}),
258                    None,
259                    None,
260                )]
261            }
262        }
263        LlmResponseChunk::TextDelta { text, .. } => {
264            vec![openai_stream_chunk(
265                state,
266                json!({"content": text}),
267                None,
268                None,
269            )]
270        }
271        LlmResponseChunk::ReasoningDelta { text, .. } => {
272            vec![openai_stream_chunk(
273                state,
274                json!({"reasoning_content": text}),
275                None,
276                None,
277            )]
278        }
279        LlmResponseChunk::ReasoningDetailsDelta { details, text, .. } => {
280            // Chat cannot replay Anthropic signature fragments or an encrypted item's
281            // id announcement without its payload.
282            let details: Vec<Value> = details
283                .into_iter()
284                .filter(|detail| match detail.get("type").and_then(Value::as_str) {
285                    Some("anthropic.signature_delta") => false,
286                    Some("reasoning.encrypted") => detail.get("data").is_some(),
287                    _ => true,
288                })
289                .collect();
290            if details.is_empty() && text.is_empty() {
291                return Vec::new();
292            }
293            let details_text = reasoning_text_from_details(&details);
294            let mut delta = json!({"reasoning_details": details});
295            if !text.is_empty() && details_text.as_deref() != Some(text.as_str()) {
296                delta["reasoning"] = Value::String(text);
297            }
298            vec![openai_stream_chunk(state, delta, None, None)]
299        }
300        LlmResponseChunk::ToolCallDelta {
301            index,
302            id,
303            name,
304            arguments_delta,
305        } => {
306            let tool = state.tool_states.entry(index).or_default();
307            // Repeated full names would be concatenated by Chat clients as new fragments.
308            let name = name.filter(|name| tool.name.as_ref() != Some(name));
309            if name.is_some() {
310                tool.name = name.clone();
311            }
312            // The source index counts every content block (Anthropic) or output item
313            // (Responses), so text ahead of the first tool call shifts it. Chat clients use
314            // the index as a subscript into `tool_calls`, so number calls in that array
315            // instead, in order of first appearance.
316            let chat_index = match tool.chat_tool_index {
317                Some(chat_index) => chat_index,
318                None => {
319                    let chat_index = state.next_chat_tool_index;
320                    state.next_chat_tool_index += 1;
321                    state.tool_states.entry(index).or_default().chat_tool_index = Some(chat_index);
322                    chat_index
323                }
324            };
325            vec![openai_tool_call_chunk(
326                state,
327                chat_index,
328                id,
329                name,
330                arguments_delta,
331            )]
332        }
333        LlmResponseChunk::Usage(usage) => {
334            state.usage = usage;
335            state.saw_backend_usage = true;
336            if state.finished {
337                vec![openai_usage_chunk(state)]
338            } else {
339                Vec::new()
340            }
341        }
342        LlmResponseChunk::MessageStop { reason } => {
343            if state.finished {
344                return Vec::new();
345            }
346            state.finished = true;
347            vec![openai_stream_chunk(
348                state,
349                json!({}),
350                Some(openai_finish_reason(reason.as_deref())),
351                state
352                    .saw_backend_usage
353                    .then(|| encode_openai_usage(&state.usage)),
354            )]
355        }
356        LlmResponseChunk::DecodeError { message } | LlmResponseChunk::StreamError { message } => {
357            // An in-band error is terminal: emit the error, then nothing further.
358            state.finished = true; // finish() adds no success events
359            state.errored = true; // the entry guard drops any later chunk
360            vec![json!({"error": {"message": message}})]
361        }
362    }
363}
364
365// Emits a terminal chunk if the source stream ended before a stop event arrived.
366fn finish_openai_chat_stream(state: &mut StreamTranslationState) -> Vec<Value> {
367    if state.finished || !state.saw_message_start {
368        return Vec::new();
369    }
370    state.finished = true;
371    vec![openai_stream_chunk(
372        state,
373        json!({}),
374        Some(openai_finish_reason(state.stop_reason.as_deref())),
375        state
376            .saw_backend_usage
377            .then(|| encode_openai_usage(&state.usage)),
378    )]
379}
380
381// Builds a single OpenAI Chat stream chunk payload.
382fn openai_stream_chunk(
383    state: &StreamTranslationState,
384    delta: Value,
385    finish_reason: Option<String>,
386    usage: Option<Value>,
387) -> Value {
388    let mut payload = json!({
389        "id": openai_stream_id(state),
390        "object": "chat.completion.chunk",
391        "created": 0,
392        "model": target_model_or_source_model(state),
393        "choices": [{
394            "index": 0,
395            "delta": delta,
396            "finish_reason": finish_reason,
397        }],
398    });
399    if let Some(usage) = usage {
400        payload["usage"] = usage;
401    }
402    payload
403}
404
405// Builds the usage-only chunk OpenAI emits after the terminal choices chunk.
406fn openai_usage_chunk(state: &StreamTranslationState) -> Value {
407    let mut payload = openai_stream_chunk(
408        state,
409        json!({}),
410        None,
411        Some(encode_openai_usage(&state.usage)),
412    );
413    payload["choices"] = json!([]);
414    payload
415}
416
417// Builds an OpenAI Chat tool-call delta chunk.
418fn openai_tool_call_chunk(
419    state: &StreamTranslationState,
420    index: usize,
421    id: Option<String>,
422    name: Option<String>,
423    arguments: Option<String>,
424) -> Value {
425    let mut function = Map::new();
426    if let Some(name) = name {
427        function.insert("name".to_string(), Value::String(name));
428    }
429    if let Some(arguments) = arguments {
430        function.insert("arguments".to_string(), Value::String(arguments));
431    }
432    let mut tool_call = Map::new();
433    tool_call.insert("index".to_string(), json!(index));
434    tool_call.insert("type".to_string(), json!("function"));
435    tool_call.insert("function".to_string(), Value::Object(function));
436    if let Some(id) = id {
437        tool_call.insert("id".to_string(), Value::String(id));
438    }
439    openai_stream_chunk(
440        state,
441        json!({"tool_calls": [Value::Object(tool_call)]}),
442        None,
443        None,
444    )
445}
446
447// Converts any upstream message ID into an OpenAI-looking stream ID.
448fn openai_stream_id(state: &StreamTranslationState) -> String {
449    let Some(id) = state.message_id.as_deref() else {
450        return "chatcmpl_switchyard".to_string();
451    };
452    if id.starts_with("chatcmpl") {
453        id.to_string()
454    } else if let Some(rest) = id.strip_prefix("msg_").or_else(|| id.strip_prefix("resp_")) {
455        format!("chatcmpl_{rest}")
456    } else {
457        format!("chatcmpl_{id}")
458    }
459}
460
461// Maps provider stop reasons into OpenAI's finish-reason vocabulary.
462fn openai_finish_reason(reason: Option<&str>) -> String {
463    match reason {
464        Some("end_turn") | Some("stop_sequence") | None => "stop".to_string(),
465        Some("max_tokens") => "length".to_string(),
466        Some("tool_use") => "tool_calls".to_string(),
467        Some("refusal") => "content_filter".to_string(),
468        Some(other) => other.to_string(),
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn recognizes_stream_completion() {
478        let codec = OpenAiChatStreamCodec;
479        assert!(codec.sse_done_is_terminal());
480        assert!(codec.is_terminal_event(&json!({"choices": [{"finish_reason": "stop"}]})));
481        assert!(!codec.is_terminal_event(&json!({"choices": [{"finish_reason": null}]})));
482    }
483
484    #[test]
485    fn openai_chat_replay_stamps_the_served_model_without_losing_extensions() {
486        let mut event = json!({
487            "choices": [{"delta": {"content": "Hello"}}],
488            "system_fingerprint": "fp_provider_specific",
489        });
490
491        OpenAiChatStreamCodec.stamp_streamed_response_model(&mut event, "served/model");
492
493        assert_eq!(event["model"], "served/model");
494        assert_eq!(event["system_fingerprint"], "fp_provider_specific");
495    }
496}