switchyard_translation/codecs/openai_chat/
stream.rs1use 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
17pub 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
70fn 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 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 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 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
233fn encode_openai_chat_stream(
235 state: &mut StreamTranslationState,
236 event: LlmResponseChunk,
237) -> Vec<Value> {
238 if state.errored {
240 return Vec::new();
241 }
242 match event {
243 LlmResponseChunk::MessageStart { id, model } => {
244 record_source_identity(state, id, model);
245 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 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 let name = name.filter(|name| tool.name.as_ref() != Some(name));
309 if name.is_some() {
310 tool.name = name.clone();
311 }
312 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 state.finished = true; state.errored = true; vec![json!({"error": {"message": message}})]
361 }
362 }
363}
364
365fn 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
381fn 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
405fn 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
417fn 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
447fn 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
461fn 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}