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