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::Serialize;
7use serde_json::{Value, json};
8
9use crate::LlmResponseChunk;
10use crate::codecs::common::{
11    collect_responses_reasoning_text, encrypted_reasoning_data, encrypted_reasoning_item_id,
12};
13use crate::codecs::stream::{
14    StreamCodec, StreamTranslationState, record_source_identity,
15    target_message_id_or_source_message_id, target_model_or_source_model,
16};
17use crate::format::{FormatId, WireFormat};
18use crate::llm::Usage;
19
20use super::buffered::encode_responses_usage;
21
22/// Stream codec for OpenAI Responses API events.
23pub struct OpenAiResponsesStreamCodec;
24
25impl StreamCodec for OpenAiResponsesStreamCodec {
26    fn format(&self) -> FormatId {
27        WireFormat::OpenAiResponses.into()
28    }
29
30    fn is_terminal_event(&self, event: &Value) -> bool {
31        matches!(
32            event
33                .get("type")
34                .or_else(|| event.get("event"))
35                .and_then(Value::as_str),
36            Some("response.completed" | "response.incomplete" | "response.failed")
37        )
38    }
39
40    fn stops_at_terminal_event(&self) -> bool {
41        true
42    }
43
44    fn sse_done_is_terminal(&self) -> bool {
45        true
46    }
47
48    fn stamp_streamed_response_model(&self, event: &mut Value, served_model: &str) {
49        if let Some(response) = event.get_mut("response").and_then(Value::as_object_mut) {
50            response.insert("model".to_string(), Value::String(served_model.to_string()));
51        }
52    }
53
54    fn decode_event(
55        &self,
56        state: &mut StreamTranslationState,
57        event: &Value,
58    ) -> Vec<LlmResponseChunk> {
59        decode_responses_stream(state, event)
60    }
61
62    fn encode_event(
63        &self,
64        state: &mut StreamTranslationState,
65        event: LlmResponseChunk,
66    ) -> Vec<Value> {
67        let events = encode_responses_stream(state, event);
68        add_sequence_numbers(state, events)
69    }
70
71    fn observe_replayed_event(
72        &self,
73        state: &mut StreamTranslationState,
74        raw: &Value,
75        normalized: Vec<LlmResponseChunk>,
76    ) {
77        let replayed_terminal = normalized
78            .iter()
79            .any(|chunk| matches!(chunk, LlmResponseChunk::MessageStop { .. }));
80        // Exact replay emits `raw` once. Normalized encodings only advance codec state;
81        // their generated events are discarded and must not create sequence-number gaps.
82        for chunk in normalized {
83            drop(encode_responses_stream(state, chunk));
84        }
85        state.response_sequence_number = raw
86            .get("sequence_number")
87            .and_then(Value::as_u64)
88            .map_or(state.response_sequence_number.saturating_add(1), |number| {
89                number.saturating_add(1)
90            });
91        if replayed_terminal {
92            state.finished = true;
93        }
94    }
95
96    fn finish(&self, state: &mut StreamTranslationState) -> Vec<Value> {
97        let events = finish_responses_stream(state);
98        add_sequence_numbers(state, events)
99    }
100}
101
102/// Required fields shared by Responses stream snapshots.
103#[derive(Serialize)]
104struct ResponsesStreamResponse {
105    id: String,
106    object: &'static str,
107    created_at: u64,
108    completed_at: Option<u64>,
109    error: Option<Value>,
110    incomplete_details: Option<Value>,
111    instructions: Option<Value>,
112    metadata: Option<Value>,
113    model: String,
114    output: Vec<Value>,
115    parallel_tool_calls: bool,
116    frequency_penalty: Option<f64>,
117    presence_penalty: Option<f64>,
118    status: &'static str,
119    temperature: Option<f64>,
120    tool_choice: &'static str,
121    tools: Vec<Value>,
122    top_p: Option<f64>,
123    usage: Value,
124}
125
126// Decodes one OpenAI Responses event into neutral streaming events.
127fn decode_responses_stream(
128    state: &mut StreamTranslationState,
129    event: &Value,
130) -> Vec<LlmResponseChunk> {
131    // Opt-in raw capture of what the upstream actually sent, for diagnosing provider-specific
132    // event shapes: `RUST_LOG=switchyard_translation::responses::raw=trace`.
133    tracing::trace!(target: "switchyard_translation::responses::raw", raw = %event);
134    let event_type = event
135        .get("type")
136        .or_else(|| event.get("event"))
137        .and_then(Value::as_str);
138    match event_type {
139        Some("response.created") => {
140            state.saw_message_start = true;
141            let response = event.get("response").and_then(Value::as_object);
142            if let Some(model) = response
143                .and_then(|response| response.get("model"))
144                .and_then(Value::as_str)
145            {
146                state.model = Some(model.to_string());
147            }
148            if let Some(id) = response
149                .and_then(|response| response.get("id"))
150                .and_then(Value::as_str)
151            {
152                state.message_id = Some(id.to_string());
153            }
154            vec![LlmResponseChunk::MessageStart {
155                id: state.message_id.clone(),
156                model: state.model.clone(),
157            }]
158        }
159        Some("response.output_text.delta") => event
160            .get("delta")
161            .and_then(Value::as_str)
162            .map(|text| {
163                let index = event
164                    .get("output_index")
165                    .and_then(Value::as_u64)
166                    .unwrap_or(0) as usize;
167                let content_index = event
168                    .get("content_index")
169                    .and_then(Value::as_u64)
170                    .unwrap_or(0) as usize;
171                state
172                    .decoded_response_text
173                    .entry(index)
174                    .or_default()
175                    .entry(content_index)
176                    .or_default()
177                    .push_str(text);
178                vec![LlmResponseChunk::TextDelta {
179                    index,
180                    text: text.to_string(),
181                }]
182            })
183            .unwrap_or_default(),
184        Some("response.reasoning_text.delta") | Some("response.reasoning_summary_text.delta") => {
185            event
186                .get("delta")
187                .or_else(|| event.get("text"))
188                .and_then(Value::as_str)
189                .map(|text| {
190                    let index = event
191                        .get("output_index")
192                        .and_then(Value::as_u64)
193                        .unwrap_or(0) as usize;
194                    // Recorded so `response.output_item.done`, which repeats the full
195                    // text, can tell it is a repeat.
196                    state
197                        .decoded_reasoning
198                        .entry(index)
199                        .or_default()
200                        .push_str(text);
201                    vec![LlmResponseChunk::ReasoningDelta {
202                        index,
203                        text: text.to_string(),
204                    }]
205                })
206                .unwrap_or_default()
207        }
208        Some("response.output_item.added") => decode_responses_output_item_added(event, state),
209        Some("response.function_call_arguments.delta") => {
210            let output_index = event
211                .get("output_index")
212                .and_then(Value::as_u64)
213                .unwrap_or(0);
214            event
215                .get("delta")
216                .and_then(Value::as_str)
217                // The decoder reads a custom call's input only from its completed item.
218                .filter(|_| {
219                    !state
220                        .tool_states
221                        .get(&(output_index as usize))
222                        .is_some_and(|tool| tool.decoded_custom_call)
223                })
224                .map(|delta| {
225                    state.decoded_tool_call = true;
226                    // Recorded so `response.output_item.done`, which repeats
227                    // the complete arguments, can tell it is a repeat.
228                    state
229                        .tool_states
230                        .entry(output_index as usize)
231                        .or_default()
232                        .decoded_arguments
233                        .push_str(delta);
234                    vec![LlmResponseChunk::ToolCallDelta {
235                        index: output_index as usize,
236                        id: None,
237                        name: None,
238                        arguments_delta: Some(delta.to_string()),
239                    }]
240                })
241                .unwrap_or_default()
242        }
243        Some("response.output_item.done") => decode_responses_output_item_done(event, state),
244        Some("response.reasoning_text.done") | Some("response.reasoning_summary_text.done") => {
245            let index = event
246                .get("output_index")
247                .and_then(Value::as_u64)
248                .unwrap_or(0) as usize;
249            event
250                .get("text")
251                .and_then(Value::as_str)
252                .filter(|text| !text.is_empty())
253                .filter(|_| {
254                    state
255                        .decoded_reasoning
256                        .get(&index)
257                        .is_none_or(String::is_empty)
258                })
259                .map(|text| {
260                    state.decoded_reasoning.insert(index, text.to_string());
261                    vec![LlmResponseChunk::ReasoningDelta {
262                        index,
263                        text: text.to_string(),
264                    }]
265                })
266                .unwrap_or_default()
267        }
268        Some("response.reasoning_summary_part.done") => {
269            let index = event
270                .get("output_index")
271                .and_then(Value::as_u64)
272                .unwrap_or(0) as usize;
273            event
274                .get("part")
275                .and_then(|part| part.get("text"))
276                .and_then(Value::as_str)
277                .filter(|text| !text.is_empty())
278                .filter(|_| {
279                    state
280                        .decoded_reasoning
281                        .get(&index)
282                        .is_none_or(String::is_empty)
283                })
284                .map(|text| {
285                    state.decoded_reasoning.insert(index, text.to_string());
286                    vec![LlmResponseChunk::ReasoningDelta {
287                        index,
288                        text: text.to_string(),
289                    }]
290                })
291                .unwrap_or_default()
292        }
293        Some("response.completed") => {
294            let mut out = Vec::new();
295            // Some providers send output only in the final snapshot. Reconcile it with
296            // decoded deltas before emitting the stop, without repeating streamed content.
297            if let Some(items) = event
298                .get("response")
299                .and_then(|response| response.get("output"))
300                .and_then(Value::as_array)
301            {
302                for (position, item) in items.iter().enumerate() {
303                    if let Some(item) = item.as_object() {
304                        out.extend(decode_responses_completed_item(item, position, state));
305                        if matches!(out.last(), Some(LlmResponseChunk::DecodeError { .. })) {
306                            return out;
307                        }
308                    }
309                }
310            }
311            decode_terminal_usage(state, event, &mut out);
312            // A completed response that produced a tool call ended the turn to run that
313            // tool, not because the assistant was done. The buffered decoder reports tool
314            // use for such output; the stream must too, or stop-reason-driven tool loops
315            // (Anthropic `tool_use`, Chat `tool_calls`) stop without running the tool.
316            // Carries the Anthropic spelling because every encoder already maps it.
317            let reason = state.decoded_tool_call.then(|| "tool_use".to_string());
318            out.push(LlmResponseChunk::MessageStop { reason });
319            out
320        }
321        // Carries the Anthropic spelling because every encoder already maps it.
322        Some("response.incomplete") => {
323            let mut out = Vec::new();
324            decode_terminal_usage(state, event, &mut out);
325            out.push(LlmResponseChunk::MessageStop {
326                reason: Some("max_tokens".to_string()),
327            });
328            out
329        }
330        Some("response.failed") => vec![LlmResponseChunk::StreamError {
331            message: event
332                .get("response")
333                .and_then(|response| response.get("error"))
334                .and_then(|error| error.get("message"))
335                .and_then(Value::as_str)
336                .unwrap_or("unknown Responses stream error")
337                .to_string(),
338        }],
339        Some("error") => vec![LlmResponseChunk::StreamError {
340            message: event
341                .get("message")
342                .and_then(Value::as_str)
343                .unwrap_or("unknown Responses stream error")
344                .to_string(),
345        }],
346        _ => Vec::new(),
347    }
348}
349
350// Encodes neutral streaming events into OpenAI Responses events.
351fn encode_responses_stream(
352    state: &mut StreamTranslationState,
353    event: LlmResponseChunk,
354) -> Vec<Value> {
355    // An in-band error is terminal: once the error is emitted, drop every later chunk.
356    if state.errored {
357        return Vec::new();
358    }
359    match event {
360        LlmResponseChunk::MessageStart { id, model } => {
361            record_source_identity(state, id, model);
362            ensure_responses_created(state)
363        }
364        LlmResponseChunk::TextDelta { text, .. } => encode_responses_text_delta(state, text),
365        LlmResponseChunk::ReasoningDelta { index, text } => {
366            encode_responses_reasoning_delta(state, index, text)
367        }
368        LlmResponseChunk::ReasoningDetailsDelta {
369            index,
370            details,
371            text,
372        } => {
373            // Encrypted reasoning has no streamable text, but the item must still be emitted
374            // so the client can replay it on the next turn.
375            let data = encrypted_reasoning_data(&details);
376            let item = state.response_reasoning.entry(index).or_default();
377            for detail in &details {
378                if detail.get("type").and_then(Value::as_str) == Some("anthropic.signature_delta")
379                    && let Some(signature) = detail.get("signature").and_then(Value::as_str)
380                {
381                    item.anthropic_signature
382                        .get_or_insert_default()
383                        .push_str(signature);
384                }
385            }
386            match reasoning_details_item_id(&details) {
387                Some(id) if item.started && item.item_id.as_deref() != Some(id.as_str()) => {
388                    // The item already opened under another id; the payload would fail
389                    // verification under it, so drop the payload rather than poison the replay.
390                    tracing::warn!(
391                        item_id = %id,
392                        "encrypted reasoning arrived after its item opened under a different id; dropping payload"
393                    );
394                }
395                id => {
396                    if id.is_some() {
397                        item.item_id = id;
398                    }
399                    if data.is_some() {
400                        item.encrypted = data;
401                    }
402                }
403            }
404            // A summary that arrives before its item id would open the item under a synthesized
405            // id, and the encrypted payload that follows binds only to the provider's id. Hold
406            // the text until that id arrives, or until later output shows none is coming.
407            if !item.started && item.item_id.is_none() && summary_lacks_item_id(&details) {
408                item.pending_text.push_str(&text);
409                return ensure_responses_created(state);
410            }
411            let mut out = ensure_responses_reasoning_started(state, index);
412            if !text.is_empty() {
413                out.extend(encode_responses_reasoning_delta(state, index, text));
414            }
415            out
416        }
417        LlmResponseChunk::ToolCallDelta {
418            index,
419            id,
420            name,
421            arguments_delta,
422        } => encode_responses_tool_delta(state, index, id, name, arguments_delta),
423        LlmResponseChunk::Usage(usage) => {
424            state.usage = usage;
425            state.saw_backend_usage = true;
426            Vec::new()
427        }
428        LlmResponseChunk::MessageStop { reason } => {
429            state.stop_reason = reason.or_else(|| state.stop_reason.clone());
430            Vec::new()
431        }
432        LlmResponseChunk::DecodeError { message } | LlmResponseChunk::StreamError { message } => {
433            // An in-band error is terminal: emit the error, then nothing further.
434            state.finished = true; // finish() adds no success events
435            state.errored = true; // the entry guard drops any later chunk
436            vec![json!({"type": "error", "message": message})]
437        }
438    }
439}
440
441// Emits final OpenAI Responses completion events from accumulated state.
442fn finish_responses_stream(state: &mut StreamTranslationState) -> Vec<Value> {
443    if state.finished {
444        return Vec::new();
445    }
446    let is_truncated = matches!(
447        state.stop_reason.as_deref(),
448        Some("length") | Some("max_tokens")
449    );
450    let (event_type, status) = if is_truncated {
451        ("response.incomplete", "incomplete")
452    } else {
453        ("response.completed", "completed")
454    };
455    let incomplete_details = is_truncated.then(|| json!({ "reason": "max_output_tokens" }));
456    let mut out = open_held_reasoning(state, None);
457    out.extend(ensure_responses_created(state));
458    if state.response_text_started
459        && let Some(output_index) = state.response_text_output_index
460    {
461        let item_id = responses_item_id(state, "msg", output_index);
462        out.push(json!({
463            "type": "response.output_text.done",
464            "item_id": item_id,
465            "output_index": output_index,
466            "content_index": 0,
467            "text": state.response_text,
468            "logprobs": [],
469        }));
470        out.push(json!({
471            "type": "response.content_part.done",
472            "item_id": item_id,
473            "output_index": output_index,
474            "content_index": 0,
475            "part": {"type": "output_text", "text": state.response_text, "annotations": [], "logprobs": []},
476        }));
477        out.push(json!({
478            "type": "response.output_item.done",
479            "output_index": output_index,
480            "item": {
481                "type": "message",
482                "id": item_id,
483                "role": "assistant",
484                "status": status,
485                "content": [{"type": "output_text", "text": state.response_text, "annotations": [], "logprobs": []}],
486            },
487        }));
488    }
489
490    let mut final_items: Vec<(usize, Value)> = Vec::new();
491    let mut reasoning_items: Vec<(usize, usize)> = state
492        .response_reasoning
493        .iter()
494        .filter(|(_, item)| item.started)
495        .filter_map(|(index, item)| item.output_index.map(|output| (*index, output)))
496        .collect();
497    reasoning_items.sort_by_key(|(_, output_index)| *output_index);
498    for (index, output_index) in reasoning_items {
499        // Encrypted-only reasoning streamed no text, so it gets no summary part; the item
500        // itself still closes so the client can replay its `encrypted_content`.
501        let item_id = responses_reasoning_item_id(state, index);
502        let reasoning = &state.response_reasoning[&index];
503        let mut summary = Vec::new();
504        if !reasoning.text.is_empty() {
505            out.push(json!({
506                "type": "response.reasoning_summary_text.done",
507                "item_id": item_id,
508                "output_index": output_index,
509                "summary_index": 0,
510                "text": reasoning.text,
511            }));
512            out.push(json!({
513                "type": "response.reasoning_summary_part.done",
514                "item_id": item_id,
515                "output_index": output_index,
516                "summary_index": 0,
517                "part": {"type": "summary_text", "text": reasoning.text},
518            }));
519            summary.push(json!({
520                "type": "summary_text",
521                "text": reasoning.text,
522            }));
523        }
524        let mut item = json!({
525            "type": "reasoning",
526            "id": item_id,
527            "status": "completed",
528            "summary": summary,
529        });
530        if let Some(signature) = &reasoning.anthropic_signature {
531            item["encrypted_content"] =
532                Value::String(super::encode_anthropic_thinking(&reasoning.text, signature));
533        } else if let Some(encrypted) = &reasoning.encrypted {
534            item["encrypted_content"] = Value::String(encrypted.clone());
535        }
536        out.push(json!({
537            "type": "response.output_item.done",
538            "output_index": output_index,
539            "item": item,
540        }));
541        final_items.push((output_index, item));
542    }
543    if state.response_text_started
544        && let Some(output_index) = state.response_text_output_index
545    {
546        final_items.push((
547            output_index,
548            json!({
549                "type": "message",
550                "id": responses_item_id(state, "msg", output_index),
551                "role": "assistant",
552                "status": status,
553                "content": [{"type": "output_text", "text": state.response_text, "annotations": [], "logprobs": []}],
554            }),
555        ));
556    }
557
558    for tool in state.tool_states.values() {
559        if !tool.started {
560            continue;
561        }
562        let output_index = tool.response_output_index.unwrap_or(0);
563        let item_id = tool
564            .response_item_id
565            .clone()
566            .unwrap_or_else(|| responses_item_id(state, "fc", output_index));
567        out.push(json!({
568            "type": "response.function_call_arguments.done",
569            "item_id": item_id,
570            "output_index": output_index,
571            "name": tool.name.clone().unwrap_or_default(),
572            "arguments": tool.arguments,
573        }));
574        let item = json!({
575            "type": "function_call",
576            "id": item_id,
577            "call_id": tool.id.clone().unwrap_or_else(|| format!("call_{output_index}")),
578            "name": tool.name.clone().unwrap_or_default(),
579            "arguments": tool.arguments,
580            "status": "completed",
581        });
582        out.push(json!({
583            "type": "response.output_item.done",
584            "output_index": output_index,
585            "item": item,
586        }));
587        final_items.push((output_index, item));
588    }
589
590    final_items.sort_by_key(|(index, _)| *index);
591    let output = final_items
592        .into_iter()
593        .map(|(_, item)| item)
594        .collect::<Vec<_>>();
595
596    out.push(json!({
597        "type": event_type,
598        "response": responses_stream_response(state, status, incomplete_details, output),
599    }));
600    state.finished = true;
601    out
602}
603
604// Converts Responses function-call item creation into a neutral tool-call delta.
605// Decodes the reasoning a provider put on a reasoning output item itself: plaintext in
606// `content`, `summary`, or top-level `text`, and/or an opaque `encrypted_content`. Text that was
607// already decoded for this output index (from delta events or an earlier item event) is not
608// repeated, so `added`, `done`, and the final `response.completed` output can all be inspected
609// safely.
610fn decode_responses_reasoning_item(
611    item: &serde_json::Map<String, Value>,
612    index: usize,
613    state: &mut StreamTranslationState,
614) -> Vec<LlmResponseChunk> {
615    let mut out = Vec::new();
616    let has_encrypted = item
617        .get("encrypted_content")
618        .and_then(Value::as_str)
619        .is_some_and(|data| !data.is_empty());
620    // The provider's item id is known from the first `added` event, long before the encrypted
621    // payload arrives on `done`. Announce it so the encoder opens the item under that id;
622    // otherwise summary text opens it under a synthesized id the payload cannot verify against.
623    if let Some(id) = item
624        .get("id")
625        .and_then(Value::as_str)
626        .filter(|id| !id.is_empty())
627        && !state.decoded_reasoning_ids.contains(&index)
628    {
629        state.decoded_reasoning_ids.insert(index);
630        if !has_encrypted {
631            out.push(LlmResponseChunk::ReasoningDetailsDelta {
632                index,
633                details: vec![json!({"type": "reasoning.encrypted", "id": id})],
634                text: String::new(),
635            });
636        }
637    }
638    let mut parts = Vec::new();
639    collect_responses_reasoning_text(item.get("content"), &mut parts);
640    collect_responses_reasoning_text(item.get("summary"), &mut parts);
641    collect_responses_reasoning_text(item.get("text"), &mut parts);
642    let text = parts.join("\n");
643    let already = state
644        .decoded_reasoning
645        .get(&index)
646        .map(String::as_str)
647        .unwrap_or("");
648    if !text.is_empty() && already.is_empty() {
649        state.decoded_reasoning.insert(index, text.clone());
650        out.push(LlmResponseChunk::ReasoningDelta { index, text });
651    }
652    if let Some(data) = item
653        .get("encrypted_content")
654        .and_then(Value::as_str)
655        .filter(|data| !data.is_empty())
656        && !state.decoded_reasoning_encrypted.contains(&index)
657    {
658        state.decoded_reasoning_encrypted.insert(index);
659        // The payload only verifies under the id it was issued with, so carry that id along.
660        let mut detail = json!({"type": "reasoning.encrypted", "data": data});
661        if let Some(id) = item
662            .get("id")
663            .and_then(Value::as_str)
664            .filter(|id| !id.is_empty())
665        {
666            detail["id"] = Value::String(id.to_string());
667        }
668        out.push(LlmResponseChunk::ReasoningDetailsDelta {
669            index,
670            details: vec![detail],
671            text: String::new(),
672        });
673    }
674    out
675}
676
677fn decode_responses_output_item_added(
678    event: &Value,
679    state: &mut StreamTranslationState,
680) -> Vec<LlmResponseChunk> {
681    let Some(item) = event.get("item").and_then(Value::as_object) else {
682        return Vec::new();
683    };
684    let index = event
685        .get("output_index")
686        .and_then(Value::as_u64)
687        .unwrap_or(0) as usize;
688    if item.get("type").and_then(Value::as_str) == Some("reasoning") {
689        return decode_responses_reasoning_item(item, index, state);
690    }
691    let item_type = item.get("type").and_then(Value::as_str);
692    if item_type != Some("function_call") && item_type != Some("custom_tool_call") {
693        return Vec::new();
694    }
695    state.decoded_tool_call = true;
696    let id = item
697        .get("call_id")
698        .or_else(|| item.get("id"))
699        .and_then(Value::as_str);
700    let name = item.get("name").and_then(Value::as_str);
701    let tool = state.tool_states.entry(index).or_default();
702    tool.has_decoded_identity |=
703        id.is_some_and(|id| !id.is_empty()) && name.is_some_and(|name| !name.is_empty());
704    let custom = item_type == Some("custom_tool_call");
705    tool.decoded_custom_call |= custom;
706    // A freeform call's `input` becomes the single `input` argument; it is only complete on
707    // the done event, so nothing is emitted for it here beyond id and name.
708    let arguments_delta = if custom {
709        None
710    } else {
711        item.get("arguments")
712            .and_then(Value::as_str)
713            .filter(|arguments| !arguments.is_empty())
714            .map(ToOwned::to_owned)
715    };
716    if let Some(arguments) = arguments_delta.as_deref() {
717        tool.decoded_arguments.push_str(arguments);
718    }
719    vec![LlmResponseChunk::ToolCallDelta {
720        index,
721        id: id.map(ToOwned::to_owned),
722        name: name.map(ToOwned::to_owned),
723        arguments_delta,
724    }]
725}
726
727fn decode_responses_output_item_done(
728    event: &Value,
729    state: &mut StreamTranslationState,
730) -> Vec<LlmResponseChunk> {
731    let Some(item) = event.get("item").and_then(Value::as_object) else {
732        return Vec::new();
733    };
734    let index = event
735        .get("output_index")
736        .and_then(Value::as_u64)
737        .unwrap_or(0) as usize;
738    decode_responses_completed_item(item, index, state)
739}
740
741fn decode_responses_completed_item(
742    item: &serde_json::Map<String, Value>,
743    index: usize,
744    state: &mut StreamTranslationState,
745) -> Vec<LlmResponseChunk> {
746    if item.get("type").and_then(Value::as_str) == Some("reasoning") {
747        return decode_responses_reasoning_item(item, index, state);
748    }
749    let item_type = item.get("type").and_then(Value::as_str);
750    if item_type == Some("message") {
751        let mut out = Vec::new();
752        if let Some(content) = item.get("content").and_then(Value::as_array) {
753            for (content_index, part) in content.iter().enumerate() {
754                if part.get("type").and_then(Value::as_str) != Some("output_text") {
755                    continue;
756                }
757                if let Some(text) = part.get("text").and_then(Value::as_str) {
758                    let decoded = state
759                        .decoded_response_text
760                        .entry(index)
761                        .or_default()
762                        .entry(content_index)
763                        .or_default();
764                    match snapshot_suffix(decoded, text) {
765                        Ok(Some(text)) => out.push(LlmResponseChunk::TextDelta { index, text }),
766                        Ok(None) => {}
767                        Err(error) => return vec![error],
768                    }
769                }
770            }
771        }
772        return out;
773    }
774    if item_type != Some("function_call") && item_type != Some("custom_tool_call") {
775        return Vec::new();
776    }
777    state.decoded_tool_call = true;
778    let custom_arguments = (item_type == Some("custom_tool_call")).then(|| {
779        json!({
780            crate::codex_custom_tools::INPUT_ARGUMENT:
781                item.get("input").and_then(Value::as_str).unwrap_or_default()
782        })
783        .to_string()
784    });
785    let arguments = custom_arguments
786        .as_deref()
787        .or_else(|| item.get("arguments").and_then(Value::as_str));
788    let tool = state.tool_states.entry(index).or_default();
789    let arguments_delta =
790        match arguments.map(|arguments| snapshot_suffix(&mut tool.decoded_arguments, arguments)) {
791            Some(Ok(delta)) => delta,
792            Some(Err(error)) => return vec![error],
793            None => None,
794        };
795    let id = item
796        .get("call_id")
797        .or_else(|| item.get("id"))
798        .and_then(Value::as_str);
799    let name = item.get("name").and_then(Value::as_str);
800    let needs_identity = !tool.has_decoded_identity;
801    tool.has_decoded_identity |=
802        id.is_some_and(|id| !id.is_empty()) && name.is_some_and(|name| !name.is_empty());
803    if needs_identity || arguments_delta.is_some() {
804        return vec![LlmResponseChunk::ToolCallDelta {
805            index,
806            id: id.filter(|_| needs_identity).map(ToOwned::to_owned),
807            name: name.filter(|_| needs_identity).map(ToOwned::to_owned),
808            arguments_delta,
809        }];
810    }
811    Vec::new()
812}
813
814// A snapshot may extend streamed content, but cannot retract content already sent. A
815// conflict is invalid provider data, so it is a `DecodeError`.
816fn snapshot_suffix(
817    decoded: &mut String,
818    snapshot: &str,
819) -> Result<Option<String>, LlmResponseChunk> {
820    let Some(suffix) = snapshot.strip_prefix(decoded.as_str()) else {
821        return Err(LlmResponseChunk::DecodeError {
822            message: "Responses snapshot conflicts with streamed content".to_string(),
823        });
824    };
825    if suffix.is_empty() {
826        return Ok(None);
827    }
828    decoded.push_str(suffix);
829    Ok(Some(suffix.to_owned()))
830}
831
832// Emits the initial Responses created event once per stream.
833fn ensure_responses_created(state: &mut StreamTranslationState) -> Vec<Value> {
834    if state.response_created {
835        return Vec::new();
836    }
837    // A provider ID learned later must not change an already published response or item ID.
838    if state.target_message_id.is_none() {
839        state.target_message_id = Some(responses_id(state));
840    }
841    state.response_created = true;
842    vec![json!({
843        "type": "response.created",
844        "response": responses_stream_response(state, "in_progress", None, Vec::new()),
845    })]
846}
847
848// Builds a schema-complete Responses snapshot for strict generated clients.
849fn responses_stream_response(
850    state: &StreamTranslationState,
851    status: &'static str,
852    incomplete_details: Option<Value>,
853    output: Vec<Value>,
854) -> ResponsesStreamResponse {
855    ResponsesStreamResponse {
856        id: responses_id(state),
857        object: "response",
858        created_at: 0,
859        completed_at: None,
860        error: None,
861        incomplete_details,
862        instructions: None,
863        metadata: None,
864        model: target_model_or_source_model(state),
865        output,
866        parallel_tool_calls: true,
867        frequency_penalty: None,
868        presence_penalty: None,
869        status,
870        temperature: None,
871        tool_choice: "auto",
872        tools: Vec::new(),
873        top_p: None,
874        usage: encode_responses_usage(&state.usage),
875    }
876}
877
878// Assigns monotonically increasing sequence numbers to generated Responses events.
879fn add_sequence_numbers(state: &mut StreamTranslationState, mut events: Vec<Value>) -> Vec<Value> {
880    for event in &mut events {
881        if let Some(object) = event.as_object_mut() {
882            object.insert(
883                "sequence_number".to_string(),
884                Value::from(state.response_sequence_number),
885            );
886            state.response_sequence_number = state.response_sequence_number.saturating_add(1);
887        }
888    }
889    events
890}
891
892// Accumulates assistant text and emits Responses text delta events.
893fn encode_responses_text_delta(state: &mut StreamTranslationState, text: String) -> Vec<Value> {
894    let mut out = open_held_reasoning(state, None);
895    out.extend(ensure_responses_created(state));
896    if !state.response_text_started {
897        state.response_text_started = true;
898        let output_index = state.next_response_output_index;
899        state.next_response_output_index += 1;
900        state.response_text_output_index = Some(output_index);
901        out.push(json!({
902            "type": "response.output_item.added",
903            "output_index": output_index,
904            "item": {
905                "type": "message",
906                "id": responses_item_id(state, "msg", output_index),
907                "role": "assistant",
908                "status": "in_progress",
909                "content": [],
910            },
911        }));
912        out.push(json!({
913            "type": "response.content_part.added",
914            "item_id": responses_item_id(state, "msg", output_index),
915            "output_index": output_index,
916            "content_index": 0,
917            "part": {"type": "output_text", "text": "", "annotations": [], "logprobs": []},
918        }));
919    }
920    state.response_text.push_str(&text);
921    out.push(json!({
922        "type": "response.output_text.delta",
923        "item_id": responses_item_id(state, "msg", state.response_text_output_index.unwrap_or(0)),
924        "output_index": state.response_text_output_index.unwrap_or(0),
925        "content_index": 0,
926        "delta": text,
927        "logprobs": [],
928    }));
929    out
930}
931
932// The provider item id named by a delta's reasoning details. The encrypted payload's id wins
933// because a replay verifies against it; a summary or text detail names the same item when it
934// arrives first.
935fn reasoning_details_item_id(details: &[Value]) -> Option<String> {
936    encrypted_reasoning_item_id(details).or_else(|| {
937        details
938            .iter()
939            .filter_map(Value::as_object)
940            .filter(|detail| {
941                matches!(
942                    detail.get("type").and_then(Value::as_str),
943                    Some("reasoning.summary" | "reasoning.text")
944                )
945            })
946            .find_map(|detail| {
947                detail
948                    .get("id")
949                    .and_then(Value::as_str)
950                    .filter(|id| !id.is_empty())
951            })
952            .map(ToOwned::to_owned)
953    })
954}
955
956// True when a `reasoning.summary` detail arrived without the id of the item it belongs to.
957fn summary_lacks_item_id(details: &[Value]) -> bool {
958    details.iter().filter_map(Value::as_object).any(|detail| {
959        detail.get("type").and_then(Value::as_str) == Some("reasoning.summary")
960            && detail
961                .get("id")
962                .and_then(Value::as_str)
963                .is_none_or(str::is_empty)
964    })
965}
966
967// Opens every reasoning item still holding summary text, under a synthesized id. Output that
968// is about to open another item proves no provider id is coming for them, and they must keep
969// their place ahead of it. When that output is itself a reasoning item, `before` limits the
970// flush to held items at earlier indices; the item's own later details can still name its id.
971fn open_held_reasoning(state: &mut StreamTranslationState, before: Option<usize>) -> Vec<Value> {
972    let held: Vec<usize> = state
973        .response_reasoning
974        .iter()
975        .filter(|(index, item)| {
976            before.is_none_or(|bound| **index < bound)
977                && !item.started
978                && !item.pending_text.is_empty()
979        })
980        .map(|(index, _)| *index)
981        .collect();
982    let mut out = Vec::new();
983    for index in held {
984        out.extend(ensure_responses_reasoning_started(state, index));
985    }
986    out
987}
988
989// The id of the reasoning item encoded for a source index: the provider's own id when a
990// reasoning detail named one, else synthesized from the emitted output index.
991fn responses_reasoning_item_id(state: &StreamTranslationState, index: usize) -> String {
992    let item = state.response_reasoning.get(&index);
993    item.and_then(|item| item.item_id.clone())
994        .unwrap_or_else(|| {
995            let output_index = item.and_then(|item| item.output_index).unwrap_or(0);
996            responses_item_id(state, "rs", output_index)
997        })
998}
999
1000// Opens the Responses reasoning output item for a source index once, emitting its `added` events.
1001fn ensure_responses_reasoning_started(
1002    state: &mut StreamTranslationState,
1003    index: usize,
1004) -> Vec<Value> {
1005    let mut out = ensure_responses_created(state);
1006    if state
1007        .response_reasoning
1008        .get(&index)
1009        .is_some_and(|item| item.started)
1010    {
1011        return out;
1012    }
1013    // Held summaries of earlier items open first, so they keep their place in the output.
1014    out.extend(open_held_reasoning(state, Some(index)));
1015    let output_index = state.next_response_output_index;
1016    state.next_response_output_index += 1;
1017    let item = state.response_reasoning.entry(index).or_default();
1018    item.started = true;
1019    item.output_index = Some(output_index);
1020    let pending = std::mem::take(&mut item.pending_text);
1021    let item_id = responses_reasoning_item_id(state, index);
1022    // Standard Responses shape: reasoning text lives in `summary` as `summary_text`
1023    // parts. Clients such as Codex record reasoning items only in this shape.
1024    out.push(json!({
1025        "type": "response.output_item.added",
1026        "output_index": output_index,
1027        "item": {
1028            "type": "reasoning",
1029            "id": item_id,
1030            "status": "in_progress",
1031            "summary": [],
1032        },
1033    }));
1034    // Summary text held for the item's id streams now, under the id it opened with.
1035    if !pending.is_empty() {
1036        out.extend(encode_responses_reasoning_delta(state, index, pending));
1037    }
1038    out
1039}
1040
1041// Opens the item's single summary part the first time text streams for it. Encrypted-only
1042// reasoning never streams text, so it never opens a part; the item closes with an empty
1043// `summary`, matching what `finish_responses_stream` emits for it.
1044fn ensure_responses_reasoning_summary_started(
1045    state: &mut StreamTranslationState,
1046    index: usize,
1047) -> Vec<Value> {
1048    let mut out = ensure_responses_reasoning_started(state, index);
1049    let item_id = responses_reasoning_item_id(state, index);
1050    let item = state.response_reasoning.entry(index).or_default();
1051    if item.summary_started {
1052        return out;
1053    }
1054    item.summary_started = true;
1055    out.push(json!({
1056        "type": "response.reasoning_summary_part.added",
1057        "item_id": item_id,
1058        "output_index": item.output_index.unwrap_or(0),
1059        "summary_index": 0,
1060        "part": {"type": "summary_text", "text": ""},
1061    }));
1062    out
1063}
1064
1065// Accumulates reasoning text and emits Responses reasoning events.
1066fn encode_responses_reasoning_delta(
1067    state: &mut StreamTranslationState,
1068    index: usize,
1069    text: String,
1070) -> Vec<Value> {
1071    // Text for an item still waiting on its id joins the held summary.
1072    if let Some(item) = state.response_reasoning.get_mut(&index)
1073        && !item.started
1074        && !item.pending_text.is_empty()
1075    {
1076        item.pending_text.push_str(&text);
1077        return Vec::new();
1078    }
1079    // An empty delta carries nothing to show and must not open a part that would never close.
1080    if text.is_empty() {
1081        return ensure_responses_reasoning_started(state, index);
1082    }
1083    let mut out = ensure_responses_reasoning_summary_started(state, index);
1084    let item = state.response_reasoning.entry(index).or_default();
1085    item.text.push_str(&text);
1086    let output_index = item.output_index.unwrap_or(0);
1087    out.push(json!({
1088        "type": "response.reasoning_summary_text.delta",
1089        "item_id": responses_reasoning_item_id(state, index),
1090        "output_index": output_index,
1091        "summary_index": 0,
1092        "delta": text,
1093    }));
1094    out
1095}
1096
1097// Accumulates tool-call state and emits Responses function-call delta events.
1098fn encode_responses_tool_delta(
1099    state: &mut StreamTranslationState,
1100    index: usize,
1101    id: Option<String>,
1102    name: Option<String>,
1103    arguments_delta: Option<String>,
1104) -> Vec<Value> {
1105    let mut out = ensure_responses_created(state);
1106    let resp_id = responses_id(state);
1107    let tool = state.tool_states.entry(index).or_default();
1108    if id.is_some() {
1109        tool.id = id;
1110    }
1111    if name.is_some() {
1112        tool.name = name;
1113    }
1114    if let Some(delta) = arguments_delta {
1115        tool.arguments.push_str(&delta);
1116        tool.pending_arguments.push_str(&delta);
1117    }
1118
1119    if !tool.started {
1120        let Some(name) = tool.name.clone() else {
1121            return out;
1122        };
1123        // The call opens its item next; held reasoning opens first to keep its place.
1124        out.extend(open_held_reasoning(state, None));
1125        let output_index = state.next_response_output_index;
1126        state.next_response_output_index += 1;
1127        let tool = state.tool_states.entry(index).or_default();
1128        tool.response_output_index = Some(output_index);
1129        tool.response_item_id = Some(responses_item_id_from(&resp_id, "fc", output_index));
1130        tool.started = true;
1131        out.push(json!({
1132            "type": "response.output_item.added",
1133            "output_index": output_index,
1134            "item": {
1135                "type": "function_call",
1136                "id": tool.response_item_id.clone().unwrap_or_else(|| responses_item_id_from(&resp_id, "fc", output_index)),
1137                "call_id": tool.id.clone().unwrap_or_else(|| format!("call_{index}")),
1138                "name": name,
1139                "arguments": "",
1140                "status": "in_progress",
1141            },
1142        }));
1143        if !tool.pending_arguments.is_empty() {
1144            out.push(json!({
1145                "type": "response.function_call_arguments.delta",
1146                "item_id": tool.response_item_id,
1147                "output_index": output_index,
1148                "delta": tool.pending_arguments,
1149            }));
1150            tool.pending_arguments.clear();
1151        }
1152        return out;
1153    }
1154
1155    if let Some(output_index) = tool.response_output_index
1156        && !tool.pending_arguments.is_empty()
1157    {
1158        out.push(json!({
1159            "type": "response.function_call_arguments.delta",
1160            "item_id": tool.response_item_id,
1161            "output_index": output_index,
1162            "delta": tool.pending_arguments,
1163        }));
1164        tool.pending_arguments.clear();
1165    }
1166    out
1167}
1168
1169// Both completed and token-limited Responses events can carry final usage and provider cost.
1170fn decode_terminal_usage(
1171    state: &mut StreamTranslationState,
1172    event: &Value,
1173    out: &mut Vec<LlmResponseChunk>,
1174) {
1175    if let Some(usage) = event
1176        .get("response")
1177        .and_then(Value::as_object)
1178        .and_then(|response| response.get("usage"))
1179        .and_then(Value::as_object)
1180    {
1181        let usage = responses_usage(usage);
1182        state.usage = usage.clone();
1183        state.saw_backend_usage = true;
1184        out.push(LlmResponseChunk::Usage(usage));
1185    }
1186}
1187
1188// Normalizes OpenAI Responses token usage fields.
1189fn responses_usage(usage: &serde_json::Map<String, Value>) -> Usage {
1190    let aggregate_input_tokens = usage.get("input_tokens").and_then(Value::as_u64);
1191    let cached_input_tokens = usage
1192        .get("input_tokens_details")
1193        .and_then(|details| details.get("cached_tokens"))
1194        .and_then(Value::as_u64);
1195    let cache_creation_input_tokens = usage
1196        .get("input_tokens_details")
1197        .and_then(|details| details.get("cache_write_tokens"))
1198        .and_then(Value::as_u64);
1199    let input_tokens = aggregate_input_tokens.map(|tokens| {
1200        tokens
1201            .saturating_sub(cached_input_tokens.unwrap_or(0))
1202            .saturating_sub(cache_creation_input_tokens.unwrap_or(0))
1203    });
1204    let output_tokens = usage.get("output_tokens").and_then(Value::as_u64);
1205    Usage {
1206        provider_cost: crate::codecs::common::provider_cost(usage),
1207        input_tokens,
1208        cache: crate::codecs::common::cache_usage_details(
1209            usage,
1210            cached_input_tokens,
1211            cache_creation_input_tokens,
1212        ),
1213        output_tokens,
1214        total_tokens: usage
1215            .get("total_tokens")
1216            .and_then(Value::as_u64)
1217            .or_else(|| Some(aggregate_input_tokens.unwrap_or(0) + output_tokens.unwrap_or(0))),
1218        reasoning_tokens: usage
1219            .get("output_tokens_details")
1220            .and_then(|details| details.get("reasoning_tokens"))
1221            .or_else(|| {
1222                usage
1223                    .get("completion_tokens_details")
1224                    .and_then(|details| details.get("reasoning_tokens"))
1225            })
1226            .and_then(Value::as_u64),
1227    }
1228}
1229
1230// Longest response-id discriminator embedded verbatim in a synthesized item id. OpenAI rejects
1231// item ids over 64 characters, and some upstreams issue response ids several hundred characters
1232// long, so anything longer is replaced by a fixed-width digest.
1233const ITEM_ID_DISCRIMINATOR_CHARS: usize = 40;
1234
1235// Builds a synthesized output-item id that is unique across responses. Clients replay the
1236// whole conversation, so ids must not repeat from one turn to the next; the response id is
1237// unique per upstream call and is used as the discriminator.
1238fn responses_item_id(state: &StreamTranslationState, prefix: &str, output_index: usize) -> String {
1239    responses_item_id_from(&responses_id(state), prefix, output_index)
1240}
1241
1242// Same as [`responses_item_id`], for callers that already hold the response id and cannot
1243// borrow `state` again (for example while a `tool_states` entry is borrowed mutably).
1244fn responses_item_id_from(resp_id: &str, prefix: &str, output_index: usize) -> String {
1245    let resp = resp_id.strip_prefix("resp_").unwrap_or(resp_id);
1246    if resp.chars().count() <= ITEM_ID_DISCRIMINATOR_CHARS {
1247        format!("{prefix}_{resp}_{output_index}")
1248    } else {
1249        format!("{prefix}_{:016x}_{output_index}", fnv1a_64(resp))
1250    }
1251}
1252
1253// FNV-1a over the UTF-8 bytes: a stable, dependency-free 64-bit digest. Uniqueness across the
1254// handful of responses in one conversation is all that is required of it.
1255fn fnv1a_64(text: &str) -> u64 {
1256    text.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
1257        (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
1258    })
1259}
1260
1261// Keep the response ID aligned with buffered responses and stored conversation history.
1262fn responses_id(state: &StreamTranslationState) -> String {
1263    target_message_id_or_source_message_id(state)
1264        .unwrap_or("resp_switchyard")
1265        .to_string()
1266}
1267
1268#[cfg(test)]
1269mod tests {
1270    use super::*;
1271
1272    #[test]
1273    fn recognizes_stream_completion() {
1274        let codec = OpenAiResponsesStreamCodec;
1275        assert!(codec.sse_done_is_terminal());
1276        for kind in [
1277            "response.completed",
1278            "response.incomplete",
1279            "response.failed",
1280        ] {
1281            assert!(codec.is_terminal_event(&json!({"type": kind})));
1282            assert!(codec.is_terminal_event(&json!({"event": kind})));
1283        }
1284        assert!(!codec.is_terminal_event(&json!({"type": "response.created"})));
1285    }
1286
1287    #[test]
1288    fn responses_replay_stamps_the_served_model_inside_response() {
1289        let mut event = json!({
1290            "type": "response.created",
1291            "response": {
1292                "id": "resp_1",
1293                "model": "provider/model",
1294                "provider_extension": true,
1295            },
1296        });
1297
1298        OpenAiResponsesStreamCodec.stamp_streamed_response_model(&mut event, "served/model");
1299
1300        assert_eq!(event["response"]["model"], "served/model");
1301        assert_eq!(event["response"]["provider_extension"], true);
1302    }
1303}