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