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