switchyard_translation/codecs/openai_chat/
stream.rs1use 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
16pub 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
45fn 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 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
151fn encode_openai_chat_stream(
153 state: &mut StreamTranslationState,
154 event: LlmResponseChunk,
155) -> Vec<Value> {
156 if state.errored {
158 return Vec::new();
159 }
160 match event {
161 LlmResponseChunk::MessageStart { id, model } => {
162 record_source_identity(state, id, model);
163 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 state.finished = true; state.errored = true; vec![json!({"error": {"message": message}})]
235 }
236 }
237}
238
239fn 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
253fn 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
291fn 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
315fn 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
322fn 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
352fn 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
380fn 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
394fn 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}