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