Skip to main content

switchyard_translation/codecs/responses/
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 Responses API events.
5
6use serde_json::{json, Value};
7
8use crate::codecs::stream::{
9    record_source_identity, target_message_id_or_source_message_id, target_model_or_source_model,
10    StreamCodec, StreamTranslationState,
11};
12use crate::format::{FormatId, WireFormat};
13use crate::llm::{LlmStreamEvent, Usage};
14
15/// Stream codec for OpenAI Responses API events.
16pub struct OpenAiResponsesStreamCodec;
17
18impl StreamCodec for OpenAiResponsesStreamCodec {
19    fn format(&self) -> FormatId {
20        WireFormat::OpenAiResponses.into()
21    }
22
23    fn decode_event(
24        &self,
25        state: &mut StreamTranslationState,
26        event: &Value,
27    ) -> Vec<LlmStreamEvent> {
28        decode_responses_stream(state, event)
29    }
30
31    fn encode_event(
32        &self,
33        state: &mut StreamTranslationState,
34        event: LlmStreamEvent,
35    ) -> Vec<Value> {
36        encode_responses_stream(state, event)
37    }
38
39    fn finish(&self, state: &mut StreamTranslationState) -> Vec<Value> {
40        finish_responses_stream(state)
41    }
42}
43
44// Decodes one OpenAI Responses event into neutral streaming events.
45fn decode_responses_stream(
46    state: &mut StreamTranslationState,
47    event: &Value,
48) -> Vec<LlmStreamEvent> {
49    let event_type = event
50        .get("type")
51        .or_else(|| event.get("event"))
52        .and_then(Value::as_str);
53    match event_type {
54        Some("response.created") => {
55            state.saw_message_start = true;
56            let response = event.get("response").and_then(Value::as_object);
57            if let Some(model) = response
58                .and_then(|response| response.get("model"))
59                .and_then(Value::as_str)
60            {
61                state.model = Some(model.to_string());
62            }
63            if let Some(id) = response
64                .and_then(|response| response.get("id"))
65                .and_then(Value::as_str)
66            {
67                state.message_id = Some(id.to_string());
68            }
69            vec![LlmStreamEvent::MessageStart {
70                id: state.message_id.clone(),
71                model: state.model.clone(),
72            }]
73        }
74        Some("response.output_text.delta") => event
75            .get("delta")
76            .and_then(Value::as_str)
77            .map(|text| {
78                vec![LlmStreamEvent::TextDelta {
79                    index: event
80                        .get("output_index")
81                        .and_then(Value::as_u64)
82                        .unwrap_or(0) as usize,
83                    text: text.to_string(),
84                }]
85            })
86            .unwrap_or_default(),
87        Some("response.reasoning_text.delta") | Some("response.reasoning_summary_text.delta") => {
88            event
89                .get("delta")
90                .or_else(|| event.get("text"))
91                .and_then(Value::as_str)
92                .map(|text| {
93                    vec![LlmStreamEvent::ReasoningDelta {
94                        index: event
95                            .get("output_index")
96                            .and_then(Value::as_u64)
97                            .unwrap_or(0) as usize,
98                        text: text.to_string(),
99                    }]
100                })
101                .unwrap_or_default()
102        }
103        Some("response.output_item.added") => decode_responses_output_item_added(event),
104        Some("response.function_call_arguments.delta") => {
105            let output_index = event
106                .get("output_index")
107                .and_then(Value::as_u64)
108                .unwrap_or(0);
109            event
110                .get("delta")
111                .and_then(Value::as_str)
112                .map(|delta| {
113                    vec![LlmStreamEvent::ToolCallDelta {
114                        index: output_index as usize,
115                        id: None,
116                        name: None,
117                        arguments_delta: Some(delta.to_string()),
118                    }]
119                })
120                .unwrap_or_default()
121        }
122        Some("response.output_item.done") => decode_responses_output_item_done(event, state),
123        Some("response.completed") => {
124            let mut out = Vec::new();
125            if let Some(usage) = event
126                .get("response")
127                .and_then(Value::as_object)
128                .and_then(|response| response.get("usage"))
129                .and_then(Value::as_object)
130            {
131                let usage = responses_usage(usage);
132                state.usage = usage.clone();
133                state.saw_backend_usage = true;
134                out.push(LlmStreamEvent::Usage(usage));
135            }
136            out.push(LlmStreamEvent::MessageStop { reason: None });
137            out
138        }
139        Some("error") => vec![LlmStreamEvent::Error {
140            message: event
141                .get("message")
142                .and_then(Value::as_str)
143                .unwrap_or("unknown Responses stream error")
144                .to_string(),
145        }],
146        _ => Vec::new(),
147    }
148}
149
150// Encodes neutral streaming events into OpenAI Responses events.
151fn encode_responses_stream(
152    state: &mut StreamTranslationState,
153    event: LlmStreamEvent,
154) -> Vec<Value> {
155    match event {
156        LlmStreamEvent::MessageStart { id, model } => {
157            record_source_identity(state, id, model);
158            ensure_responses_created(state)
159        }
160        LlmStreamEvent::TextDelta { text, .. } => encode_responses_text_delta(state, text),
161        LlmStreamEvent::ReasoningDelta { text, .. } => {
162            encode_responses_reasoning_delta(state, text)
163        }
164        LlmStreamEvent::ToolCallDelta {
165            index,
166            id,
167            name,
168            arguments_delta,
169        } => encode_responses_tool_delta(state, index, id, name, arguments_delta),
170        LlmStreamEvent::Usage(usage) => {
171            state.usage = usage;
172            state.saw_backend_usage = true;
173            Vec::new()
174        }
175        LlmStreamEvent::MessageStop { reason } => {
176            state.stop_reason = reason.or_else(|| state.stop_reason.clone());
177            Vec::new()
178        }
179        LlmStreamEvent::Error { message } => {
180            vec![json!({"type": "error", "message": message})]
181        }
182    }
183}
184
185// Emits final OpenAI Responses completion events from accumulated state.
186fn finish_responses_stream(state: &mut StreamTranslationState) -> Vec<Value> {
187    let mut out = ensure_responses_created(state);
188    if state.response_text_started {
189        if let Some(output_index) = state.response_text_output_index {
190            out.push(json!({
191                "type": "response.content_part.done",
192                "output_index": output_index,
193                "content_index": 0,
194                "part": {"type": "output_text", "text": state.response_text},
195            }));
196            out.push(json!({
197                "type": "response.output_item.done",
198                "output_index": output_index,
199                "item": {
200                    "type": "message",
201                    "role": "assistant",
202                    "status": "completed",
203                    "content": [{"type": "output_text", "text": state.response_text}],
204                },
205            }));
206        }
207    }
208
209    let mut final_items: Vec<(usize, Value)> = Vec::new();
210    if state.response_reasoning_started {
211        if let Some(output_index) = state.response_reasoning_output_index {
212            out.push(json!({
213                "type": "response.reasoning_text.done",
214                "output_index": output_index,
215                "content_index": 0,
216                "text": state.response_reasoning_text,
217            }));
218            let item = json!({
219                "type": "reasoning",
220                "id": format!("rs_{output_index}"),
221                "status": "completed",
222                "content": [{
223                    "type": "reasoning_text",
224                    "text": state.response_reasoning_text,
225                }],
226                "summary": [],
227            });
228            out.push(json!({
229                "type": "response.output_item.done",
230                "output_index": output_index,
231                "item": item,
232            }));
233            final_items.push((output_index, item));
234        }
235    }
236    if state.response_text_started {
237        if let Some(output_index) = state.response_text_output_index {
238            final_items.push((
239                output_index,
240                json!({
241                    "type": "message",
242                    "role": "assistant",
243                    "status": "completed",
244                    "content": [{"type": "output_text", "text": state.response_text}],
245                }),
246            ));
247        }
248    }
249
250    for tool in state.tool_states.values() {
251        if !tool.started {
252            continue;
253        }
254        let output_index = tool.response_output_index.unwrap_or(0);
255        out.push(json!({
256            "type": "response.function_call_arguments.done",
257            "output_index": output_index,
258            "arguments": tool.arguments,
259        }));
260        let item = json!({
261            "type": "function_call",
262            "id": tool.response_item_id.clone().unwrap_or_else(|| format!("fc_{output_index}")),
263            "call_id": tool.id.clone().unwrap_or_else(|| format!("call_{output_index}")),
264            "name": tool.name.clone().unwrap_or_default(),
265            "arguments": tool.arguments,
266            "status": "completed",
267        });
268        out.push(json!({
269            "type": "response.output_item.done",
270            "output_index": output_index,
271            "item": item,
272        }));
273        final_items.push((output_index, item));
274    }
275
276    final_items.sort_by_key(|(index, _)| *index);
277    let output = final_items
278        .into_iter()
279        .map(|(_, item)| item)
280        .collect::<Vec<_>>();
281
282    out.push(json!({
283        "type": "response.completed",
284        "response": {
285            "id": responses_id(state),
286            "object": "response",
287            "status": "completed",
288            "model": target_model_or_source_model(state),
289            "output": output,
290            "usage": responses_usage_value(&state.usage),
291        },
292    }));
293    state.finished = true;
294    out
295}
296
297// Converts Responses function-call item creation into a neutral tool-call delta.
298fn decode_responses_output_item_added(event: &Value) -> Vec<LlmStreamEvent> {
299    let Some(item) = event.get("item").and_then(Value::as_object) else {
300        return Vec::new();
301    };
302    if item.get("type").and_then(Value::as_str) != Some("function_call") {
303        return Vec::new();
304    }
305    let index = event
306        .get("output_index")
307        .and_then(Value::as_u64)
308        .unwrap_or(0) as usize;
309    vec![LlmStreamEvent::ToolCallDelta {
310        index,
311        id: item
312            .get("call_id")
313            .or_else(|| item.get("id"))
314            .and_then(Value::as_str)
315            .map(ToOwned::to_owned),
316        name: item
317            .get("name")
318            .and_then(Value::as_str)
319            .map(ToOwned::to_owned),
320        arguments_delta: item
321            .get("arguments")
322            .and_then(Value::as_str)
323            .filter(|arguments| !arguments.is_empty())
324            .map(ToOwned::to_owned),
325    }]
326}
327
328// Emits a final tool-call argument delta when Responses only supplies arguments at item end.
329fn decode_responses_output_item_done(
330    event: &Value,
331    state: &StreamTranslationState,
332) -> Vec<LlmStreamEvent> {
333    let Some(item) = event.get("item").and_then(Value::as_object) else {
334        return Vec::new();
335    };
336    if item.get("type").and_then(Value::as_str) != Some("function_call") {
337        return Vec::new();
338    }
339    let index = event
340        .get("output_index")
341        .and_then(Value::as_u64)
342        .unwrap_or(0) as usize;
343    let arguments = item.get("arguments").and_then(Value::as_str);
344    if let Some(arguments) = arguments {
345        let existing = state
346            .tool_states
347            .get(&index)
348            .map(|tool| tool.arguments.as_str())
349            .unwrap_or("");
350        if !arguments.is_empty() && arguments != existing {
351            return vec![LlmStreamEvent::ToolCallDelta {
352                index,
353                id: None,
354                name: None,
355                arguments_delta: Some(arguments.to_string()),
356            }];
357        }
358    }
359    Vec::new()
360}
361
362// Emits the initial Responses created event once per stream.
363fn ensure_responses_created(state: &mut StreamTranslationState) -> Vec<Value> {
364    if state.response_created {
365        return Vec::new();
366    }
367    state.response_created = true;
368    vec![json!({
369        "type": "response.created",
370        "response": {
371            "id": responses_id(state),
372            "object": "response",
373            "status": "in_progress",
374            "model": target_model_or_source_model(state),
375            "output": [],
376            "usage": responses_usage_value(&state.usage),
377        },
378    })]
379}
380
381// Accumulates assistant text and emits Responses text delta events.
382fn encode_responses_text_delta(state: &mut StreamTranslationState, text: String) -> Vec<Value> {
383    let mut out = ensure_responses_created(state);
384    if !state.response_text_started {
385        state.response_text_started = true;
386        let output_index = state.next_response_output_index;
387        state.next_response_output_index += 1;
388        state.response_text_output_index = Some(output_index);
389        out.push(json!({
390            "type": "response.output_item.added",
391            "output_index": output_index,
392            "item": {
393                "type": "message",
394                "id": format!("msg_{output_index}"),
395                "role": "assistant",
396                "status": "in_progress",
397                "content": [],
398            },
399        }));
400        out.push(json!({
401            "type": "response.content_part.added",
402            "output_index": output_index,
403            "content_index": 0,
404            "part": {"type": "output_text", "text": ""},
405        }));
406    }
407    state.response_text.push_str(&text);
408    out.push(json!({
409        "type": "response.output_text.delta",
410        "output_index": state.response_text_output_index.unwrap_or(0),
411        "content_index": 0,
412        "delta": text,
413    }));
414    out
415}
416
417// Accumulates reasoning text and emits Responses reasoning events.
418fn encode_responses_reasoning_delta(
419    state: &mut StreamTranslationState,
420    text: String,
421) -> Vec<Value> {
422    let mut out = ensure_responses_created(state);
423    if !state.response_reasoning_started {
424        state.response_reasoning_started = true;
425        let output_index = state.next_response_output_index;
426        state.next_response_output_index += 1;
427        state.response_reasoning_output_index = Some(output_index);
428        out.push(json!({
429            "type": "response.output_item.added",
430            "output_index": output_index,
431            "item": {
432                "type": "reasoning",
433                "id": format!("rs_{output_index}"),
434                "status": "in_progress",
435                "content": [],
436                "summary": [],
437            },
438        }));
439        out.push(json!({
440            "type": "response.reasoning_text.added",
441            "output_index": output_index,
442            "content_index": 0,
443            "text": "",
444        }));
445    }
446    state.response_reasoning_text.push_str(&text);
447    out.push(json!({
448        "type": "response.reasoning_text.delta",
449        "output_index": state.response_reasoning_output_index.unwrap_or(0),
450        "content_index": 0,
451        "delta": text,
452    }));
453    out
454}
455
456// Accumulates tool-call state and emits Responses function-call delta events.
457fn encode_responses_tool_delta(
458    state: &mut StreamTranslationState,
459    index: usize,
460    id: Option<String>,
461    name: Option<String>,
462    arguments_delta: Option<String>,
463) -> Vec<Value> {
464    let mut out = ensure_responses_created(state);
465    let tool = state.tool_states.entry(index).or_default();
466    if id.is_some() {
467        tool.id = id;
468    }
469    if name.is_some() {
470        tool.name = name;
471    }
472    if let Some(delta) = arguments_delta {
473        tool.arguments.push_str(&delta);
474        tool.pending_arguments.push_str(&delta);
475    }
476
477    if !tool.started {
478        let Some(name) = tool.name.clone() else {
479            return out;
480        };
481        let output_index = state.next_response_output_index;
482        state.next_response_output_index += 1;
483        tool.response_output_index = Some(output_index);
484        tool.response_item_id = Some(format!("fc_{output_index}"));
485        tool.started = true;
486        out.push(json!({
487            "type": "response.output_item.added",
488            "output_index": output_index,
489            "item": {
490                "type": "function_call",
491                "id": tool.response_item_id.clone().unwrap_or_else(|| format!("fc_{output_index}")),
492                "call_id": tool.id.clone().unwrap_or_else(|| format!("call_{index}")),
493                "name": name,
494                "arguments": "",
495                "status": "in_progress",
496            },
497        }));
498        if !tool.pending_arguments.is_empty() {
499            out.push(json!({
500                "type": "response.function_call_arguments.delta",
501                "output_index": output_index,
502                "delta": tool.pending_arguments,
503            }));
504            tool.pending_arguments.clear();
505        }
506        return out;
507    }
508
509    if let Some(output_index) = tool.response_output_index {
510        if !tool.pending_arguments.is_empty() {
511            out.push(json!({
512                "type": "response.function_call_arguments.delta",
513                "output_index": output_index,
514                "delta": tool.pending_arguments,
515            }));
516            tool.pending_arguments.clear();
517        }
518    }
519    out
520}
521
522// Normalizes OpenAI Responses token usage fields.
523fn responses_usage(usage: &serde_json::Map<String, Value>) -> Usage {
524    let input_tokens = usage.get("input_tokens").and_then(Value::as_u64);
525    let output_tokens = usage.get("output_tokens").and_then(Value::as_u64);
526    Usage {
527        input_tokens,
528        output_tokens,
529        total_tokens: usage
530            .get("total_tokens")
531            .and_then(Value::as_u64)
532            .or_else(|| Some(input_tokens.unwrap_or(0) + output_tokens.unwrap_or(0))),
533        reasoning_tokens: usage
534            .get("output_tokens_details")
535            .and_then(|details| details.get("reasoning_tokens"))
536            .or_else(|| {
537                usage
538                    .get("completion_tokens_details")
539                    .and_then(|details| details.get("reasoning_tokens"))
540            })
541            .and_then(Value::as_u64),
542    }
543}
544
545// Builds OpenAI Responses usage payloads from normalized usage.
546fn responses_usage_value(usage: &Usage) -> Value {
547    let mut value = json!({
548        "input_tokens": usage.input_tokens.unwrap_or(0),
549        "output_tokens": usage.output_tokens.unwrap_or(0),
550        "total_tokens": usage.total_tokens.unwrap_or_else(|| {
551            usage.input_tokens.unwrap_or(0) + usage.output_tokens.unwrap_or(0)
552        }),
553    });
554    if let Some(reasoning_tokens) = usage.reasoning_tokens {
555        value["output_tokens_details"] = json!({
556            "reasoning_tokens": reasoning_tokens,
557        });
558    }
559    value
560}
561
562// Converts any upstream message ID into a Responses-looking response ID.
563fn responses_id(state: &StreamTranslationState) -> String {
564    let Some(id) = target_message_id_or_source_message_id(state) else {
565        return "resp_switchyard".to_string();
566    };
567    if id.starts_with("resp_") {
568        id.to_string()
569    } else {
570        format!("resp_{id}")
571    }
572}