switchyard_translation/codecs/openai_chat/
stream.rs1use 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
15pub 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
44fn 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
139fn 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
211fn 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
225fn 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
243fn 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
256fn 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
280fn 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
310fn 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
344fn 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
358fn 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}