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