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