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 crate::LlmResponseChunk;
9use crate::codecs::stream::{
10    StreamCodec, StreamTranslationState, record_source_identity, state_source_is, string_field,
11    target_model_or_source_model,
12};
13use crate::format::{FormatId, WireFormat};
14use crate::llm::Usage;
15
16/// Stream codec for OpenAI Chat Completions chunks.
17pub struct OpenAiChatStreamCodec;
18
19impl StreamCodec for OpenAiChatStreamCodec {
20    fn format(&self) -> FormatId {
21        WireFormat::OpenAiChat.into()
22    }
23
24    fn decode_event(
25        &self,
26        state: &mut StreamTranslationState,
27        event: &Value,
28    ) -> Vec<LlmResponseChunk> {
29        decode_openai_chat_stream(state, event)
30    }
31
32    fn encode_event(
33        &self,
34        state: &mut StreamTranslationState,
35        event: LlmResponseChunk,
36    ) -> Vec<Value> {
37        encode_openai_chat_stream(state, event)
38    }
39
40    fn finish(&self, state: &mut StreamTranslationState) -> Vec<Value> {
41        finish_openai_chat_stream(state)
42    }
43}
44
45// Decodes one OpenAI Chat chunk into neutral streaming events.
46fn decode_openai_chat_stream(
47    state: &mut StreamTranslationState,
48    event: &Value,
49) -> Vec<LlmResponseChunk> {
50    let Some(object) = event.as_object() else {
51        return vec![LlmResponseChunk::DecodeError {
52            message: "OpenAI stream event is not an object".to_string(),
53        }];
54    };
55
56    // Upstream error frames carry no choices, so without this they would decode to a bare
57    // `MessageStart` and the error text would be dropped.
58    if let Some(error) = object.get("error") {
59        return vec![LlmResponseChunk::StreamError {
60            message: error
61                .get("message")
62                .and_then(Value::as_str)
63                .unwrap_or("unknown OpenAI stream error")
64                .to_string(),
65        }];
66    }
67
68    let mut out = Vec::new();
69    if !state.saw_message_start {
70        state.saw_message_start = true;
71        if let Some(model) = string_field(object, "model") {
72            state.model = Some(model);
73        }
74        if let Some(id) = string_field(object, "id") {
75            state.message_id = Some(id);
76        }
77        out.push(LlmResponseChunk::MessageStart {
78            id: state.message_id.clone(),
79            model: state.model.clone(),
80        });
81    }
82
83    if let Some(usage) = object.get("usage").and_then(Value::as_object) {
84        let usage = openai_usage(usage);
85        state.usage = usage.clone();
86        state.saw_backend_usage = true;
87        out.push(LlmResponseChunk::Usage(usage));
88    }
89
90    for choice in object
91        .get("choices")
92        .and_then(Value::as_array)
93        .into_iter()
94        .flatten()
95    {
96        let Some(choice) = choice.as_object() else {
97            continue;
98        };
99        if let Some(delta) = choice.get("delta").and_then(Value::as_object) {
100            if let Some(text) = delta.get("content").and_then(Value::as_str)
101                && !text.is_empty()
102            {
103                out.push(LlmResponseChunk::TextDelta {
104                    index: 0,
105                    text: text.to_string(),
106                });
107            }
108            for reasoning_key in ["reasoning_content", "reasoning"] {
109                if let Some(text) = delta.get(reasoning_key).and_then(Value::as_str)
110                    && !text.is_empty()
111                {
112                    out.push(LlmResponseChunk::ReasoningDelta {
113                        index: 0,
114                        text: text.to_string(),
115                    });
116                }
117            }
118            if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) {
119                for tool_call in tool_calls {
120                    if let Some(tool_call) = tool_call.as_object() {
121                        let function = tool_call.get("function").and_then(Value::as_object);
122                        out.push(LlmResponseChunk::ToolCallDelta {
123                            index: tool_call.get("index").and_then(Value::as_u64).unwrap_or(0)
124                                as usize,
125                            id: tool_call
126                                .get("id")
127                                .and_then(Value::as_str)
128                                .map(ToOwned::to_owned),
129                            name: function
130                                .and_then(|function| function.get("name"))
131                                .and_then(Value::as_str)
132                                .map(ToOwned::to_owned),
133                            arguments_delta: function
134                                .and_then(|function| function.get("arguments"))
135                                .and_then(Value::as_str)
136                                .map(ToOwned::to_owned),
137                        });
138                    }
139                }
140            }
141        }
142        if let Some(reason) = choice.get("finish_reason").and_then(Value::as_str) {
143            out.push(LlmResponseChunk::MessageStop {
144                reason: Some(reason.to_string()),
145            });
146        }
147    }
148    out
149}
150
151// Encodes neutral streaming events into OpenAI Chat chunks.
152fn encode_openai_chat_stream(
153    state: &mut StreamTranslationState,
154    event: LlmResponseChunk,
155) -> Vec<Value> {
156    // An in-band error is terminal: once the error is emitted, drop every later chunk.
157    if state.errored {
158        return Vec::new();
159    }
160    match event {
161        LlmResponseChunk::MessageStart { id, model } => {
162            record_source_identity(state, id, model);
163            // `finish_openai_chat_stream` uses this flag to decide whether a clean EOF needs a
164            // synthesized terminal chunk. Set it on the encode path as well as the decode path.
165            state.saw_message_start = true;
166            if state.emitted_message_start
167                || (!state_source_is(state, WireFormat::AnthropicMessages)
168                    && !state_source_is(state, WireFormat::OpenAiResponses))
169            {
170                Vec::new()
171            } else {
172                state.emitted_message_start = true;
173                vec![openai_stream_chunk(
174                    state,
175                    json!({"role": "assistant"}),
176                    None,
177                    None,
178                )]
179            }
180        }
181        LlmResponseChunk::TextDelta { text, .. } => {
182            vec![openai_stream_chunk(
183                state,
184                json!({"content": text}),
185                None,
186                None,
187            )]
188        }
189        LlmResponseChunk::ReasoningDelta { text, .. } => {
190            vec![openai_stream_chunk(
191                state,
192                json!({"reasoning_content": text}),
193                None,
194                None,
195            )]
196        }
197        LlmResponseChunk::ToolCallDelta {
198            index,
199            id,
200            name,
201            arguments_delta,
202        } => vec![openai_tool_call_chunk(
203            state,
204            index,
205            id,
206            name,
207            arguments_delta,
208        )],
209        LlmResponseChunk::Usage(usage) => {
210            state.usage = usage;
211            state.saw_backend_usage = true;
212            if state.finished {
213                vec![openai_usage_chunk(state)]
214            } else {
215                Vec::new()
216            }
217        }
218        LlmResponseChunk::MessageStop { reason } => {
219            if state.finished {
220                return Vec::new();
221            }
222            state.finished = true;
223            vec![openai_stream_chunk(
224                state,
225                json!({}),
226                Some(openai_finish_reason(reason.as_deref())),
227                state.saw_backend_usage.then(|| openai_usage_value(state)),
228            )]
229        }
230        LlmResponseChunk::DecodeError { message } | LlmResponseChunk::StreamError { message } => {
231            // An in-band error is terminal: emit the error, then nothing further.
232            state.finished = true; // finish() adds no success events
233            state.errored = true; // the entry guard drops any later chunk
234            vec![json!({"error": {"message": message}})]
235        }
236    }
237}
238
239// Emits a terminal chunk if the source stream ended before a stop event arrived.
240fn finish_openai_chat_stream(state: &mut StreamTranslationState) -> Vec<Value> {
241    if state.finished || !state.saw_message_start {
242        return Vec::new();
243    }
244    state.finished = true;
245    vec![openai_stream_chunk(
246        state,
247        json!({}),
248        Some(openai_finish_reason(state.stop_reason.as_deref())),
249        state.saw_backend_usage.then(|| openai_usage_value(state)),
250    )]
251}
252
253// Normalizes OpenAI token usage fields.
254fn openai_usage(usage: &Map<String, Value>) -> Usage {
255    let cached_input_tokens = usage
256        .get("prompt_tokens_details")
257        .and_then(|details| details.get("cached_tokens"))
258        .and_then(Value::as_u64);
259    let cache_creation_input_tokens = usage
260        .get("prompt_tokens_details")
261        .and_then(|details| {
262            details
263                .get("cache_write_tokens")
264                .or_else(|| details.get("cache_creation_tokens"))
265        })
266        .and_then(Value::as_u64);
267    Usage {
268        input_tokens: usage
269            .get("prompt_tokens")
270            .and_then(Value::as_u64)
271            .map(|tokens| {
272                tokens
273                    .saturating_sub(cached_input_tokens.unwrap_or(0))
274                    .saturating_sub(cache_creation_input_tokens.unwrap_or(0))
275            }),
276        cache: Usage::cache_details(cached_input_tokens, cache_creation_input_tokens),
277        output_tokens: usage.get("completion_tokens").and_then(Value::as_u64),
278        total_tokens: usage.get("total_tokens").and_then(Value::as_u64),
279        reasoning_tokens: usage
280            .get("completion_tokens_details")
281            .and_then(|details| details.get("reasoning_tokens"))
282            .or_else(|| {
283                usage
284                    .get("output_tokens_details")
285                    .and_then(|details| details.get("reasoning_tokens"))
286            })
287            .and_then(Value::as_u64),
288    }
289}
290
291// Builds a single OpenAI Chat stream chunk payload.
292fn openai_stream_chunk(
293    state: &StreamTranslationState,
294    delta: Value,
295    finish_reason: Option<String>,
296    usage: Option<Value>,
297) -> Value {
298    let mut payload = json!({
299        "id": openai_stream_id(state),
300        "object": "chat.completion.chunk",
301        "created": 0,
302        "model": target_model_or_source_model(state),
303        "choices": [{
304            "index": 0,
305            "delta": delta,
306            "finish_reason": finish_reason,
307        }],
308    });
309    if let Some(usage) = usage {
310        payload["usage"] = usage;
311    }
312    payload
313}
314
315// Builds the usage-only chunk OpenAI emits after the terminal choices chunk.
316fn openai_usage_chunk(state: &StreamTranslationState) -> Value {
317    let mut payload = openai_stream_chunk(state, json!({}), None, Some(openai_usage_value(state)));
318    payload["choices"] = json!([]);
319    payload
320}
321
322// Builds an OpenAI Chat tool-call delta chunk.
323fn openai_tool_call_chunk(
324    state: &StreamTranslationState,
325    index: usize,
326    id: Option<String>,
327    name: Option<String>,
328    arguments: Option<String>,
329) -> Value {
330    let mut function = Map::new();
331    if let Some(name) = name {
332        function.insert("name".to_string(), Value::String(name));
333    }
334    if let Some(arguments) = arguments {
335        function.insert("arguments".to_string(), Value::String(arguments));
336    }
337    let mut tool_call = Map::new();
338    tool_call.insert("index".to_string(), json!(index));
339    tool_call.insert("type".to_string(), json!("function"));
340    tool_call.insert("function".to_string(), Value::Object(function));
341    if let Some(id) = id {
342        tool_call.insert("id".to_string(), Value::String(id));
343    }
344    openai_stream_chunk(
345        state,
346        json!({"tool_calls": [Value::Object(tool_call)]}),
347        None,
348        None,
349    )
350}
351
352// Builds OpenAI usage payloads from normalized and provider-extra state.
353fn openai_usage_value(state: &StreamTranslationState) -> Value {
354    let cache_creation_tokens = state.usage.cache_creation_input_tokens().unwrap_or(0);
355    let cache_read_tokens = state.usage.cached_input_tokens().unwrap_or(0);
356    let prompt_tokens =
357        state.usage.input_tokens.unwrap_or(0) + cache_creation_tokens + cache_read_tokens;
358    let completion_tokens = state.usage.output_tokens.unwrap_or(0);
359    let mut usage = json!({
360        "prompt_tokens": prompt_tokens,
361        "completion_tokens": completion_tokens,
362        "total_tokens": state.usage.total_tokens.unwrap_or(prompt_tokens + completion_tokens),
363    });
364    if let Some(reasoning_tokens) = state.usage.reasoning_tokens {
365        usage["completion_tokens_details"] = json!({
366            "reasoning_tokens": reasoning_tokens,
367        });
368    }
369    if state.usage.cache_creation_input_tokens().is_some()
370        || state.usage.cached_input_tokens().is_some()
371    {
372        usage["prompt_tokens_details"] = json!({
373            "cached_tokens": cache_read_tokens,
374            "cache_creation_tokens": cache_creation_tokens,
375        });
376    }
377    usage
378}
379
380// Converts any upstream message ID into an OpenAI-looking stream ID.
381fn openai_stream_id(state: &StreamTranslationState) -> String {
382    let Some(id) = state.message_id.as_deref() else {
383        return "chatcmpl_switchyard".to_string();
384    };
385    if id.starts_with("chatcmpl") {
386        id.to_string()
387    } else if let Some(rest) = id.strip_prefix("msg_").or_else(|| id.strip_prefix("resp_")) {
388        format!("chatcmpl_{rest}")
389    } else {
390        format!("chatcmpl_{id}")
391    }
392}
393
394// Maps provider stop reasons into OpenAI's finish-reason vocabulary.
395fn openai_finish_reason(reason: Option<&str>) -> String {
396    match reason {
397        Some("end_turn") | Some("stop_sequence") | None => "stop".to_string(),
398        Some("max_tokens") => "length".to_string(),
399        Some("tool_use") => "tool_calls".to_string(),
400        Some(other) => other.to_string(),
401    }
402}