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 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
46fn 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 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 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 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
209fn encode_openai_chat_stream(
211 state: &mut StreamTranslationState,
212 event: LlmResponseChunk,
213) -> Vec<Value> {
214 if state.errored {
216 return Vec::new();
217 }
218 match event {
219 LlmResponseChunk::MessageStart { id, model } => {
220 record_source_identity(state, id, model);
221 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 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 let name = name.filter(|name| tool.name.as_ref() != Some(name));
285 if name.is_some() {
286 tool.name = name.clone();
287 }
288 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 state.finished = true; state.errored = true; vec![json!({"error": {"message": message}})]
337 }
338 }
339}
340
341fn 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
357fn 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
381fn 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
393fn 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
423fn 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
437fn 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}