Skip to main content

rig_core/providers/openai/responses_api/
streaming.rs

1//! The streaming module for the OpenAI Responses API.
2//! Please see the `openai_streaming` or `openai_streaming_with_tools` example for more practical usage.
3use crate::completion::{self, CompletionError};
4use crate::http_client::HttpClientExt;
5use crate::http_client::sse::GenericEventSource;
6use crate::providers::internal::adapter::{
7    AdapterOutput, WireAdapter, WireFrame, run_wire_buffered,
8};
9use crate::providers::internal::sse_transport::{
10    FrameDisposition, OpenLog, SseTransportOptions, open_wire_stream,
11};
12use crate::providers::internal::wire::{self, WireEvent};
13use crate::providers::openai::responses_api::{
14    IncompleteDetailsReason, ReasoningSummary, ResponseStatus, ResponsesUsage,
15};
16use crate::streaming;
17use crate::streaming::RawStreamingChoice;
18use crate::telemetry::{CompletionOperation, CompletionSpanBuilder};
19use crate::wasm_compat::WasmCompatSend;
20use futures::StreamExt;
21use serde::{Deserialize, Serialize};
22
23use super::{CompletionResponse, GenericResponsesCompletionModel, Output, ResponsesProviderExt};
24
25type StreamingRawChoice = RawStreamingChoice<StreamingCompletionResponse>;
26
27// ================================================================
28// OpenAI Responses Streaming API
29// ================================================================
30
31/// A streaming completion chunk.
32/// Streaming chunks can come in one of two forms:
33/// - A response chunk (where the completed response will have the total token usage)
34/// - An item chunk commonly referred to as a delta. In the completions API this would be referred to as the message delta.
35#[derive(Debug, Serialize, Deserialize, Clone)]
36#[serde(untagged)]
37pub enum StreamingCompletionChunk {
38    Response(Box<ResponseChunk>),
39    Delta(ItemChunk),
40}
41
42/// The final streaming response from the OpenAI Responses API.
43///
44/// This is the provider-native terminal record carried by
45/// [`GenericResponsesCompletionModel::raw_stream`]. The normalized path maps it
46/// once, through [`crate::streaming::normalize_stream`], into a
47/// [`streaming::StreamFinal`].
48#[derive(Debug, Serialize, Deserialize, Clone)]
49pub struct StreamingCompletionResponse {
50    /// Token usage
51    pub usage: ResponsesUsage,
52    /// The complete object-shaped reasoning metadata from the terminal response event.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub reasoning_metadata: Option<serde_json::Map<String, serde_json::Value>>,
55    /// The effective reasoning context from the terminal response event.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub reasoning_context: Option<String>,
58    /// The `status` reported by the terminal `response.completed` event.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub status: Option<ResponseStatus>,
61    /// Why the response stopped short, when the provider said so.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub incomplete_details: Option<IncompleteDetailsReason>,
64    /// The assistant message ID (`msg_...`) carried by the terminal response's
65    /// output items.
66    ///
67    /// Distinct from [`Self::response_id`] (`resp_...`), which names the whole
68    /// response.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub message_id: Option<String>,
71    /// The response ID (`resp_...`) reported by the terminal
72    /// `response.completed` event.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub response_id: Option<String>,
75    /// The model identifier reported by the terminal response event.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub model: Option<String>,
78    /// The transport request id from the SSE connection's `x-request-id`
79    /// response header — not part of any stream frame; stamped by the
80    /// transport. `None` when the provider did not report one.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub provider_request_id: Option<String>,
83}
84
85impl StreamingCompletionResponse {
86    /// Create a terminal record carrying only usage; the remaining metadata is
87    /// filled in from the terminal `response.completed` event as it arrives.
88    pub fn new(usage: ResponsesUsage) -> Self {
89        Self {
90            usage,
91            provider_request_id: None,
92            reasoning_metadata: None,
93            reasoning_context: None,
94            status: None,
95            incomplete_details: None,
96            message_id: None,
97            response_id: None,
98            model: None,
99        }
100    }
101}
102
103/// Normalize the Responses API's terminal stream record.
104///
105/// The provider descriptor name is an input for the same reason it is on the
106/// unary conversion: ChatGPT and Copilot stream this exact wire shape, so a
107/// baked-in `"openai"` would mislabel them.
108///
109/// The finish reason is left exactly as the provider reported it;
110/// [`crate::streaming::normalize_stream`] applies the tool-call reconciliation
111/// afterwards, using the calls the stream actually emitted.
112impl From<(&str, StreamingCompletionResponse)> for streaming::StreamFinal {
113    fn from((provider, response): (&str, StreamingCompletionResponse)) -> Self {
114        let finish_reason = response.status.as_ref().and_then(|status| {
115            super::map_finish_reason(status, response.incomplete_details.as_ref())
116        });
117
118        streaming::StreamFinal::new(provider, crate::completion::Usage::from(&response.usage))
119            .with_optional_finish_reason(finish_reason)
120            .with_optional_message_id(response.message_id)
121            .with_optional_response_id(response.response_id)
122            .with_optional_provider_request_id(response.provider_request_id)
123            .with_optional_model(response.model)
124    }
125}
126
127/// Normalize a provider-native Responses stream for `provider`.
128///
129/// Maps only the terminal record; every incremental event passes through
130/// untouched.
131pub(crate) fn normalize_responses_stream(
132    provider: &str,
133    raw: streaming::RawStreamingResult<StreamingCompletionResponse>,
134) -> streaming::StreamingCompletionResponse {
135    let provider = provider.to_owned();
136    let mapped_provider = provider.clone();
137    let normalized = streaming::normalize_stream(raw, move |response| {
138        Ok(streaming::StreamFinal::from((
139            mapped_provider.as_str(),
140            response,
141        )))
142    });
143
144    streaming::StreamingCompletionResponse::stream(provider, normalized)
145}
146
147/// The done item's blocks as ONE authoritative end-of-part restatement.
148///
149/// Every block — summaries, content texts, `encrypted_content` — belongs to
150/// one `rs_*` reasoning item, so it must land in one part: emitting a
151/// whole-block choice per entry made every block after the first a sibling
152/// part under the same key, and history then replayed duplicate reasoning
153/// input items carrying the identical `rs_*` id. The restatement supersedes
154/// the delta-built part in place (wire field order: summary, content,
155/// encrypted). `None` when the item carries no blocks — an empty done item
156/// says nothing at the boundary.
157pub(crate) fn reasoning_end_from_done_item(
158    id: &crate::streaming::StreamPartId,
159    provider_id: Option<&crate::streaming::WireId>,
160    summary: Vec<ReasoningSummary>,
161    content: Vec<String>,
162    encrypted_content: Option<String>,
163) -> Option<RawStreamingChoice<StreamingCompletionResponse>> {
164    // Same builder as the unary decode, so the restatement and the
165    // non-streaming conversion of one item cannot drift.
166    let blocks = super::reasoning_content_blocks(summary, content, encrypted_content);
167
168    if blocks.is_empty() {
169        return None;
170    }
171
172    Some(RawStreamingChoice::ReasoningEnd {
173        id: id.clone(),
174        reasoning: Some(crate::message::Reasoning {
175            id: provider_id.map(|provider_id| provider_id.as_str().to_owned()),
176            content: blocks,
177        }),
178        signature: None,
179        wire_sent: true,
180    })
181}
182
183impl From<&StreamingCompletionResponse> for crate::completion::Usage {
184    fn from(response: &StreamingCompletionResponse) -> Self {
185        Self::from(&response.usage)
186    }
187}
188
189/// A response chunk from OpenAI's response API.
190#[derive(Debug, Serialize, Deserialize, Clone)]
191pub struct ResponseChunk {
192    /// The response chunk type
193    #[serde(rename = "type")]
194    pub kind: ResponseChunkKind,
195    /// The response itself
196    pub response: CompletionResponse,
197    /// The item sequence
198    pub sequence_number: u64,
199}
200
201/// Response chunk type.
202/// Renames are used to ensure that this type gets (de)serialized properly.
203#[derive(Debug, Serialize, Deserialize, Clone)]
204pub enum ResponseChunkKind {
205    #[serde(rename = "response.created")]
206    ResponseCreated,
207    #[serde(rename = "response.in_progress")]
208    ResponseInProgress,
209    #[serde(rename = "response.completed")]
210    ResponseCompleted,
211    #[serde(rename = "response.failed")]
212    ResponseFailed,
213    #[serde(rename = "response.incomplete")]
214    ResponseIncomplete,
215}
216
217fn provider_response_from_responses_error_value(
218    value: &serde_json::Value,
219    data: &str,
220) -> CompletionError {
221    if let Some(message) = value
222        .get("error")
223        .and_then(|error| error.get("message"))
224        .and_then(serde_json::Value::as_str)
225    {
226        tracing::warn!(message, "provider returned a streaming error event");
227    }
228
229    crate::provider_response::completion_error_from_body(data)
230}
231
232/// Whether `kind` is a Responses SSE event type this client models.
233///
234/// The union of [`ResponseChunkKind`]'s and [`ItemChunkKind`]'s wire names: a
235/// frame carrying one of these that still fails to deserialize is a data-level
236/// defect in a known event, not an unknown event type, and must surface as an
237/// error rather than be skipped.
238fn is_known_responses_event_type(kind: &str) -> bool {
239    matches!(
240        kind,
241        "response.created"
242            | "response.in_progress"
243            | "response.completed"
244            | "response.failed"
245            | "response.incomplete"
246            | "response.output_item.added"
247            | "response.output_item.done"
248            | "response.content_part.added"
249            | "response.content_part.done"
250            | "response.output_text.delta"
251            | "response.output_text.done"
252            | "response.refusal.delta"
253            | "response.refusal.done"
254            | "response.function_call_arguments.delta"
255            | "response.function_call_arguments.done"
256            | "response.reasoning_summary_part.added"
257            | "response.reasoning_summary_part.done"
258            | "response.reasoning_summary_text.delta"
259            | "response.reasoning_summary_text.done"
260            | "response.reasoning_text.delta"
261            | "response.reasoning_text.done"
262    )
263}
264
265/// Classify one Responses SSE frame; see
266/// [`crate::providers::internal::wire`] for the dispatch contract.
267///
268/// Shared by the live SSE loop, the buffered [`raw_choices_from_sse_body`]
269/// path, and the websocket session so all apply the same known/unknown
270/// boundary. Provider `error` events (and the websocket-only `response.done`)
271/// are checked separately before this, because their `type` is outside the
272/// modeled set yet must not be skipped as unknown.
273pub(super) fn classify_responses_frame(data: &str) -> WireEvent<StreamingCompletionChunk> {
274    wire::classify_tagged_frame(data, "type", is_known_responses_event_type)
275}
276
277fn provider_response_from_responses_sse_data(data: &str) -> Option<CompletionError> {
278    let value = serde_json::from_str::<serde_json::Value>(data).ok()?;
279    (value.get("type").and_then(serde_json::Value::as_str) == Some("error"))
280        .then(|| provider_response_from_responses_error_value(&value, data))
281}
282
283#[derive(Clone, Copy)]
284pub(crate) enum ResponsesStreamOptions {
285    Strict,
286    StrictWithImmediateToolCalls,
287}
288
289impl ResponsesStreamOptions {
290    pub(crate) const fn strict() -> Self {
291        Self::Strict
292    }
293
294    pub(crate) const fn strict_with_immediate_tool_calls() -> Self {
295        Self::StrictWithImmediateToolCalls
296    }
297
298    const fn emits_completed_tool_calls_immediately(self) -> bool {
299        matches!(self, Self::StrictWithImmediateToolCalls)
300    }
301}
302
303/// The payload of every content-bearing `data:` line in a buffered SSE body.
304///
305/// Blank lines, non-`data:` fields (SSE comments, `event:`), and the `[DONE]`
306/// sentinel are skipped, so both buffered readers below see exactly the frame
307/// payloads a live transport would deliver.
308fn sse_data_frames(body: &str) -> impl Iterator<Item = &str> {
309    body.lines()
310        .map(|line| {
311            line.strip_prefix("data:")
312                .map(str::trim)
313                .unwrap_or_default()
314        })
315        .filter(|data| !data.is_empty() && *data != "[DONE]")
316}
317
318pub(crate) fn parse_sse_completion_body(
319    body: &str,
320    provider_name: &str,
321) -> Result<CompletionResponse, CompletionError> {
322    let mut completed = None;
323
324    for data in sse_data_frames(body) {
325        if let Ok(chunk) = serde_json::from_str::<StreamingCompletionChunk>(data) {
326            if let StreamingCompletionChunk::Response(chunk) = chunk {
327                let ResponseChunk { kind, response, .. } = *chunk;
328                match kind {
329                    // `response.incomplete` is a genuine terminal; the unary
330                    // conversion maps its status to a finish reason.
331                    ResponseChunkKind::ResponseCompleted
332                    | ResponseChunkKind::ResponseIncomplete => {
333                        completed = Some(response);
334                        break;
335                    }
336                    ResponseChunkKind::ResponseFailed => {
337                        return Err(crate::provider_response::completion_error_from_body(data));
338                    }
339                    _ => {}
340                }
341            }
342            continue;
343        }
344
345        let value = match serde_json::from_str::<serde_json::Value>(data) {
346            Ok(value) => value,
347            Err(_) => continue,
348        };
349
350        match value.get("type").and_then(serde_json::Value::as_str) {
351            Some("response.completed") | Some("response.incomplete") => {
352                if let Some(response) = value.get("response") {
353                    completed = Some(serde_json::from_value(response.clone())?);
354                    break;
355                }
356            }
357            Some("response.failed") => {
358                return Err(crate::provider_response::completion_error_from_body(data));
359            }
360            Some("error") => {
361                return Err(provider_response_from_responses_error_value(&value, data));
362            }
363            _ => {}
364        }
365    }
366
367    completed.ok_or_else(|| {
368        CompletionError::ProviderError(format!(
369            "{provider_name} stream did not yield a terminal response event (response.completed or response.incomplete)"
370        ))
371    })
372}
373
374pub(crate) struct RawChoiceAccumulator {
375    final_usage: ResponsesUsage,
376    reasoning_metadata: Option<serde_json::Map<String, serde_json::Value>>,
377    reasoning_context: Option<String>,
378    status: Option<ResponseStatus>,
379    incomplete_details: Option<IncompleteDetailsReason>,
380    message_id: Option<String>,
381    response_id: Option<String>,
382    model: Option<String>,
383    /// Buffered tool-input end events for calls delivered whole by
384    /// `output_item.done`, flushed at the terminal (or before a terminal
385    /// error). Assembly and internal-id correlation live in the shared
386    /// accumulator, keyed by the function-call item id the added/delta/done
387    /// events share.
388    tool_calls: Vec<StreamingRawChoice>,
389    /// Whether a genuine terminal event (`response.completed` or
390    /// `response.incomplete`) arrived. Without one the stream was truncated,
391    /// and `finish` withholds the terminal record.
392    saw_terminal: bool,
393    /// Slot-scoped reasoning identity, mirroring `tool_slots`: one assembly
394    /// key per output slot, fixed at the slot's FIRST reasoning event (wire
395    /// `rs_*` id when it carries one, else minted `output-{index}`) and
396    /// reused by every later frame regardless of the id it carries.
397    /// Gateways and ChatGPT's envelope-less replay bodies omit the id on a
398    /// subset of a slot's events; per-event resolution split one slot into
399    /// `Wire("rs_1")` and `Minted(Output, i)` halves, and the done item
400    /// superseded only one of them — the other survived as an orphaned
401    /// partial part carrying the same provider id (#2258 F3 and its mixed
402    /// generalization).
403    reasoning_slots: std::collections::HashMap<u64, crate::streaming::StreamPartId>,
404    /// Tool-call identities minted for function-call items whose wire events
405    /// carried no `fc_*` id (gateways and the ChatGPT envelope-less replay
406    /// bodies), keyed by output slot. Mirrors `minted_reasoning_ids`: the
407    /// added/delta/done events of one item must all share one assembly key —
408    /// forwarding `""` verbatim would let two parallel id-less calls share the
409    /// empty key, and an id-less delta whose done restates a real `fc_*` id
410    /// would leave the fragments dangling under a different key.
411    /// Slot-scoped tool identity: one assembly key per output slot, fixed at
412    /// the slot's first event (wire `fc_*` id, else minted `output-{index}`),
413    /// reused by every later event regardless of the id it carries — mixed
414    /// id/id-less events on one slot can no longer split assembly keys.
415    tool_slots: crate::providers::internal::tool_call_bridge::ToolCallBridge<u64>,
416    /// The `call_…` correlator each open slot announced on
417    /// `output_item.added`, kept beside the bridge so a slot closed by the
418    /// terminal drain (its `output_item.done` frame was lost) still
419    /// finalizes with the dual-wire identity Responses replay pairs on.
420    pending_call_ids: std::collections::HashMap<u64, String>,
421    /// The message item whose text block is currently open. A text or
422    /// refusal delta carrying a different `item_id` opens a new text block
423    /// (`TextStart` keyed by that item id), so two `message` output items
424    /// aggregate as two distinct text parts instead of concatenating.
425    /// Deltas without an `item_id` (ChatGPT's envelope-less replays) extend
426    /// the open block, or open a boundary-minted one downstream.
427    current_text_item: Option<String>,
428}
429
430impl RawChoiceAccumulator {
431    pub(crate) fn new(initial_usage: ResponsesUsage) -> Self {
432        Self {
433            final_usage: initial_usage,
434            reasoning_metadata: None,
435            reasoning_context: None,
436            status: None,
437            incomplete_details: None,
438            message_id: None,
439            response_id: None,
440            model: None,
441            tool_calls: Vec::new(),
442            saw_terminal: false,
443            reasoning_slots: std::collections::HashMap::new(),
444            tool_slots:
445                crate::providers::internal::tool_call_bridge::ToolCallBridge::with_minted_namespace(
446                    crate::streaming::SyntheticIds::output(),
447                ),
448            pending_call_ids: std::collections::HashMap::new(),
449            current_text_item: None,
450        }
451    }
452
453    /// Open the text block for the message item a text/refusal delta belongs
454    /// to, when the wire identifies it and it differs from the open one.
455    fn start_text_item(
456        &mut self,
457        item_id: &Option<String>,
458        immediate: &mut Vec<StreamingRawChoice>,
459    ) {
460        if let Some(item_id) = item_id
461            && self.current_text_item.as_deref() != Some(item_id)
462        {
463            self.current_text_item = Some(item_id.clone());
464            immediate.push(streaming::RawStreamingChoice::TextStart {
465                id: crate::streaming::StreamPartId::wire(item_id.clone()),
466                additional_params: None,
467            });
468        }
469    }
470
471    /// The slot's reasoning assembly key, fixed at its first reasoning
472    /// event: the wire's `rs_*` id when that first frame carries one, else
473    /// a minted `output-{index}` identity. Every later frame on the slot
474    /// reuses the stored key regardless of the id it carries — the same
475    /// discipline as `tool_slots` — so mixed id/id-less frames cannot
476    /// split one slot's assembly. A late-arriving wire id upgrades the
477    /// part's durable `provider_id` (carried as data on each event), never
478    /// the accumulation key.
479    fn reasoning_slot_key(
480        &mut self,
481        output_index: u64,
482        item_id: Option<&str>,
483    ) -> crate::streaming::StreamPartId {
484        if let Some(key) = self.reasoning_slots.get(&output_index) {
485            return key.clone();
486        }
487        // Minted from the bridge's ONE counter (tool_call_bridge's own
488        // invariant): a second sequence stamping `Minted{Output, index}`
489        // could collide with an assembly the bridge minted the same value
490        // for. The per-slot map above, not the mint, is what keeps the key
491        // stable across the slot's frames.
492        let key = item_id
493            .map(crate::streaming::StreamPartId::wire)
494            .unwrap_or_else(|| self.tool_slots.minted_ids().mint());
495        self.reasoning_slots.insert(output_index, key.clone());
496        key
497    }
498
499    pub(crate) fn decode_item_chunk(
500        &mut self,
501        chunk: ItemChunk,
502        options: ResponsesStreamOptions,
503    ) -> Vec<StreamingRawChoice> {
504        let mut immediate = Vec::new();
505
506        let ItemChunk {
507            item_id: outer_item_id,
508            output_index,
509            data: item,
510        } = chunk;
511
512        match item {
513            ItemChunkKind::OutputItemAdded(StreamingItemDoneOutput {
514                item: Output::FunctionCall(func),
515                ..
516            }) => {
517                // A function-call item interleaving a message item closes the
518                // open text block; forget it so a later delta for that message
519                // re-emits `TextStart` and reactivates its block downstream.
520                self.current_text_item = None;
521                // Slot identity is established here once (wire `fc_*` id,
522                // else a minted `output-{index}`) and reused for every later
523                // event on this slot — gateways and ChatGPT's envelope-less
524                // replay bodies can omit the id on any subset of a slot's
525                // events, and event-scoped resolution would split the
526                // assembly key.
527                let key = self
528                    .tool_slots
529                    .open(output_index, Some(&func.id), Some(&func.name))
530                    .key()
531                    .to_owned();
532                if !func.call_id.is_empty() {
533                    self.pending_call_ids
534                        .insert(output_index, func.call_id.clone());
535                }
536                immediate.push(streaming::RawStreamingChoice::ToolCallDelta {
537                    id: key,
538                    content: streaming::ToolCallDeltaContent::Name(func.name),
539                });
540            }
541            ItemChunkKind::OutputItemDone(message) => {
542                // Any completed item ends the block it carried; a text delta
543                // arriving afterwards belongs to a (re)opened block.
544                self.current_text_item = None;
545                self.push_output_item_done(
546                    message.item,
547                    output_index,
548                    &mut immediate,
549                    options.emits_completed_tool_calls_immediately(),
550                );
551            }
552            // Text and refusal deltas are the same visible-text stream: a
553            // refusal is the assistant's message for that turn, and both
554            // (re)open the item's text block before their fragment.
555            ItemChunkKind::OutputTextDelta(DeltaTextChunk { delta, .. })
556            | ItemChunkKind::RefusalDelta(DeltaTextChunk { delta, .. }) => {
557                self.start_text_item(&outer_item_id, &mut immediate);
558                immediate.push(streaming::RawStreamingChoice::Message(delta));
559            }
560            // Summary and raw-reasoning deltas differ only in which wire
561            // event carries them; both are fragments of the output item's
562            // reasoning block and accumulate under its slot identity.
563            ItemChunkKind::ReasoningSummaryTextDelta(SummaryTextChunk { delta, .. })
564            | ItemChunkKind::ReasoningTextDelta(DeltaTextChunkWithItemId { delta, .. }) => {
565                // Reasoning interleaving text closes the open text block
566                // downstream (`PartsAccumulator::reasoning_delta`); forget the
567                // open message item so a later delta for the *same* item
568                // re-emits `TextStart {id}` and reactivates its block instead
569                // of silently opening a boundary-minted sibling (#2258 P2).
570                self.current_text_item = None;
571                let id = self.reasoning_slot_key(output_index, outer_item_id.as_deref());
572                immediate.push(streaming::RawStreamingChoice::ReasoningDelta {
573                    id,
574                    provider_id: outer_item_id
575                        .clone()
576                        .and_then(crate::streaming::WireId::new),
577                    reasoning: delta,
578                });
579            }
580            ItemChunkKind::FunctionCallArgsDelta(delta) => {
581                // Tool output interleaving text is a block boundary too.
582                self.current_text_item = None;
583                // The slot's established identity keys the fragment; an
584                // id-less delta on a never-opened slot mints it here so the
585                // fragments survive truncation before the authoritative
586                // `output_item.done` restatement (#2258 P3). A late wire id
587                // updates the slot's reported id without moving the key.
588                let slot = self
589                    .tool_slots
590                    .open(output_index, outer_item_id.as_deref(), None);
591                slot.saw_arguments_delta = true;
592                let key = slot.key().clone();
593                immediate.push(streaming::RawStreamingChoice::ToolCallDelta {
594                    id: key,
595                    content: streaming::ToolCallDeltaContent::Delta(delta.delta),
596                });
597            }
598            _ => {}
599        }
600
601        immediate
602    }
603
604    pub(crate) fn record_response_chunk(
605        &mut self,
606        kind: ResponseChunkKind,
607        response: CompletionResponse,
608        raw_event_data: &str,
609    ) -> Result<(), CompletionError> {
610        match kind {
611            // `response.incomplete` is a genuine terminal (e.g. hitting
612            // `max_output_tokens`): the partial output and usage are kept, and
613            // the recorded status/incomplete_details map to the finish reason
614            // downstream, matching the unary path's `map_finish_reason`.
615            ResponseChunkKind::ResponseCompleted | ResponseChunkKind::ResponseIncomplete => {
616                self.saw_terminal = true;
617                // The provider proved the turn ended, so a slot still open
618                // here lost only its `output_item.done` frame — the same
619                // terminal-drain the sibling adapters ship (Interactions at
620                // `interaction.completed`, chat-compat at `finish_reason`).
621                // Closing it lets the shared accumulator finalize the call
622                // from its streamed fragments (parse-or-drop), instead of
623                // discarding a provider-completed call as truncation.
624                for (index, slot) in self.tool_slots.drain_ordered_indexed() {
625                    let mut end = slot.end_event(streaming::UnparseableToolInput::Drop);
626                    end.call_id = self.pending_call_ids.remove(&index);
627                    self.tool_calls
628                        .push(streaming::RawStreamingChoice::ToolInputEnd(end));
629                }
630                // The terminal event is the only place the stream learns how the
631                // turn ended, which model answered, and which assistant message
632                // (`msg_...`, not the response's `resp_...`) carried the output.
633                if let Some(message_id) = message_id_from_response(&response) {
634                    self.message_id = Some(message_id);
635                }
636                if !response.id.is_empty() {
637                    self.response_id = Some(response.id.clone());
638                }
639                if !response.model.is_empty() {
640                    self.model = Some(response.model.clone());
641                }
642                self.status = Some(response.status);
643                if response.incomplete_details.is_some() {
644                    self.incomplete_details = response.incomplete_details;
645                }
646                if let Some(usage) = response.usage {
647                    self.final_usage = usage;
648                }
649                if response.reasoning_metadata.is_some() {
650                    self.reasoning_metadata = response.reasoning_metadata;
651                }
652                if response.reasoning_context.is_some() {
653                    self.reasoning_context = response.reasoning_context;
654                }
655                Ok(())
656            }
657            ResponseChunkKind::ResponseFailed => Err(
658                crate::provider_response::completion_error_from_body(raw_event_data),
659            ),
660            _ => Ok(()),
661        }
662    }
663
664    fn push_output_item_done(
665        &mut self,
666        item: Output,
667        output_index: u64,
668        immediate: &mut Vec<StreamingRawChoice>,
669        emit_completed_tool_calls_immediately: bool,
670    ) {
671        match item {
672            Output::FunctionCall(func) => {
673                // The done item restates the call whole; its fields are
674                // authoritative over any assembled fragments, and the shared
675                // accumulator correlates by the shared item id (minting the
676                // internal id if no fragments preceded).
677                //
678                // Identity mirrors the reasoning arm below: when this slot's
679                // added/delta events carried no `fc_*` id they were keyed by
680                // the minted `output-{index}` identity, and the done event
681                // must find that same key (even when it restates a real id)
682                // or the assembled fragments dangle. A slot with no minted
683                // identity keeps the wire id, minting only when it is empty.
684                let slot = self.tool_slots.remove(output_index);
685                // The done item restates its own call_id; the announce-time
686                // copy is only for slots the terminal drain must close.
687                self.pending_call_ids.remove(&output_index);
688                let item_id = match &slot {
689                    // The slot's established key wins even when the done item
690                    // restates a real `fc_*` id — assembled fragments must
691                    // not dangle under a different key.
692                    Some(slot) => slot.key().clone(),
693                    // Minted from the bridge's ONE counter: a done-only call
694                    // stamping `Minted{Output, index}` from a second sequence
695                    // could collide with a mid-assembly key the bridge minted
696                    // the same value for, consuming that assembly under the
697                    // wrong call.
698                    None if func.id.is_empty() => self.tool_slots.minted_ids().mint(),
699                    None => crate::streaming::StreamPartId::wire(func.id.clone()),
700                };
701                let mut end = streaming::ToolInputEnd::new(
702                    item_id.clone(),
703                    streaming::UnparseableToolInput::Drop,
704                );
705                end.name = Some(func.name);
706                // The finalized call reports the authoritative wire id even
707                // when assembly keyed on a minted slot identity (the
708                // accumulator honors the override).
709                end.tool_id = crate::streaming::WireId::new(func.id.clone());
710                // The restated arguments are authoritative when they parse. A
711                // turn cut by `max_output_tokens` mid-tool-call restates them
712                // truncated mid-JSON (item status `incomplete`); routing the
713                // raw string through the assembly buffer instead lets the
714                // shared accumulator apply the settled truncation policy
715                // (`UnparseableToolInput::Drop` — partial arguments never
716                // fabricate a call), including when no argument fragments
717                // preceded the done item.
718                match func.arguments.parse() {
719                    Ok(arguments) => end.arguments = Some(arguments),
720                    // Fragments already streamed these bytes into the
721                    // assembly buffer — re-emitting the restatement doubled
722                    // them (rendered twice by delta consumers and
723                    // double-charged against the accumulation bound). Only a
724                    // fragment-less done item (pure replay of a truncated
725                    // restatement) routes its raw string through the buffer,
726                    // so the truncation policy still has bytes to judge.
727                    Err(_) => {
728                        let saw_fragments =
729                            slot.as_ref().is_some_and(|slot| slot.saw_arguments_delta);
730                        if !saw_fragments {
731                            immediate.push(streaming::RawStreamingChoice::ToolCallDelta {
732                                id: item_id,
733                                content: streaming::ToolCallDeltaContent::Delta(
734                                    func.arguments.as_str().to_owned(),
735                                ),
736                            });
737                        }
738                    }
739                }
740                end.call_id = Some(func.call_id);
741                let end = streaming::RawStreamingChoice::ToolInputEnd(end);
742
743                if emit_completed_tool_calls_immediately {
744                    immediate.push(end);
745                } else {
746                    self.tool_calls.push(end);
747                }
748            }
749            Output::Reasoning {
750                id,
751                summary,
752                content,
753                encrypted_content,
754                ..
755            } => {
756                // The done item resolves through the slot map: its full
757                // blocks must share whatever identity the slot's deltas
758                // established (wire or minted) to supersede the delta-built
759                // part — keying them by the item's own `rs_*` id would
760                // append the restated content beside a minted-keyed part.
761                // A slot with no established identity keeps the wire id
762                // (the pure-replay shape). The durable handle is the item's
763                // real `rs_*` id regardless of the accumulation key.
764                let provider_id = crate::streaming::WireId::new(id.clone());
765                let key = self
766                    .reasoning_slots
767                    .remove(&output_index)
768                    .unwrap_or(crate::streaming::StreamPartId::wire(id));
769                immediate.extend(reasoning_end_from_done_item(
770                    &key,
771                    provider_id.as_ref(),
772                    summary,
773                    content,
774                    encrypted_content,
775                ));
776            }
777            Output::Message(message) => {
778                immediate.push(streaming::RawStreamingChoice::MessageId(message.id));
779            }
780            // An unmodeled output item (e.g. a hosted-tool result such as
781            // `web_search_call`) arriving on `response.output_item.done`. Surface
782            // the raw item to stream consumers, mirroring how the non-streaming
783            // decode preserves it on `CompletionResponse.output`.
784            Output::Unknown(value) => {
785                immediate.push(streaming::RawStreamingChoice::Unknown(value.into()));
786            }
787        }
788    }
789
790    /// Drain the buffered fully-delivered tool calls without finishing the
791    /// stream. The errored-terminal path flushes these before the error and
792    /// must not produce a terminal record.
793    pub(crate) fn take_tool_calls(&mut self) -> Vec<StreamingRawChoice> {
794        std::mem::take(&mut self.tool_calls)
795    }
796
797    pub(crate) fn finish(mut self) -> Vec<StreamingRawChoice> {
798        let mut choices = Vec::new();
799        choices.append(&mut self.tool_calls);
800        // Only a genuine terminal event (`response.completed` or
801        // `response.incomplete`) counts as the provider ending the turn; a
802        // stream that ended without one was truncated,
803        // and a synthesized terminal record would present the partial turn as
804        // a successful, default-usage completion.
805        if !self.saw_terminal {
806            return choices;
807        }
808        choices.push(RawStreamingChoice::FinalResponse(
809            StreamingCompletionResponse {
810                usage: self.final_usage,
811                // Stamped by the transport layer.
812                provider_request_id: None,
813                reasoning_metadata: self.reasoning_metadata,
814                reasoning_context: self.reasoning_context,
815                status: self.status,
816                incomplete_details: self.incomplete_details,
817                message_id: self.message_id,
818                response_id: self.response_id,
819                model: self.model,
820            },
821        ));
822        choices
823    }
824}
825
826/// Repair an envelope-less Responses frame so the shared typed decode can
827/// interpret it.
828///
829/// ChatGPT's replayed (unary) SSE bodies omit envelope bookkeeping fields
830/// (`sequence_number`, `output_index`, `content_index`, `summary_index`)
831/// that the typed frame decode requires. Those fields are bookkeeping only —
832/// no semantic decision reads them beyond the reasoning-identity fallback,
833/// which treats a missing `output_index` as `0` anyway — so injecting
834/// neutral zeros where they are absent turns salvage into a preprocessing
835/// step in front of the ONE event interpreter instead of a second one.
836/// Data-level fields (`delta`, `item`, `response`, …) are never touched, so
837/// a frame that is defective in its content still fails the re-decode.
838///
839/// **Policy decision — buffered-only, deliberately asymmetric with the live
840/// loop (#2258 F8):** a *live* SSE or websocket frame with a known `type` but
841/// a missing envelope field classifies `Corrupt` and surfaces as an in-band
842/// `Err` item; it is never repaired. Only ChatGPT's replayed unary bodies
843/// verifiably omit the envelope bookkeeping (every recorded live Copilot and
844/// OpenAI cassette carries full envelopes), so on a live wire an
845/// envelope-less known frame is evidence of a defective gateway, and
846/// silently repairing it would mask the defect the `Corrupt` classification
847/// exists to surface. The old live behavior (skip) hid the frame entirely;
848/// the `Err` item is the stated uniform policy for defective known frames.
849///
850/// **Known limit — identity collapse, and why the obvious fix is worse
851/// (#2258 G2):** the injected `output_index: 0` is the reasoning-identity
852/// fallback's key when `item_id` is also absent (see `reasoning_item_id` in
853/// [`RawChoiceAccumulator::decode_item_chunk`]). A body that omits BOTH
854/// `item_id` *and* `output_index` across two or more items therefore collapses
855/// them onto the single minted identity `output-0`, merging what the provider
856/// sent as separate reasoning parts. A sweep of every recorded cassette found
857/// **zero** bodies of that shape: ChatGPT's replayed bodies drop the
858/// bookkeeping fields but keep `item_id`, and every body that drops `item_id`
859/// carries `output_index`. So the collapse is reachable in principle and
860/// unobserved in practice.
861///
862/// It is deliberately NOT fixed with a per-frame counter (mint `0, 1, 2, …` as
863/// frames arrive). Envelope-less bodies are exactly the ones where consecutive
864/// frames belong to the SAME item: a counter would hand every delta of one
865/// reasoning block a different index, shattering one item into N single-delta
866/// parts. That is a real regression against a real recorded shape — it breaks
867/// `envelope_less_reasoning_deltas_are_superseded_by_their_done_item`, whose
868/// whole point is that the deltas and their `output_item.done` share one
869/// identity. Any future fix must key on something the body actually carries
870/// (item boundaries), not on arrival order.
871///
872/// Returns `None` when the frame is not a JSON object (nothing to repair).
873fn repair_envelope_less_frame(data: &str) -> Option<String> {
874    let mut value = serde_json::from_str::<serde_json::Value>(data).ok()?;
875    let object = value.as_object_mut()?;
876    for field in [
877        "sequence_number",
878        "output_index",
879        "content_index",
880        "summary_index",
881    ] {
882        object
883            .entry(field)
884            .or_insert_with(|| serde_json::Value::from(0));
885    }
886    serde_json::to_string(&value).ok()
887}
888
889pub(crate) fn raw_choices_from_sse_body(
890    body: &str,
891    initial_usage: ResponsesUsage,
892) -> Result<Vec<StreamingRawChoice>, CompletionError> {
893    // Framing layer for the buffered (unary) Responses SSE body: line
894    // splitting, sentinel skipping, and the provider `error` envelope
895    // pre-check (which fails the operation, mirroring the live transport).
896    // Classification and policy live in the buffered driver.
897    let mut frames = Vec::new();
898    for data in sse_data_frames(body) {
899        if let Some(error) = provider_response_from_responses_sse_data(data) {
900            return Err(error);
901        }
902
903        frames.push(WireFrame::Text(data.to_owned()));
904    }
905
906    // The SAME interpreter as the live loop (`classify_responses_frame`
907    // feeding `RawChoiceAccumulator`), under [`run_wire_buffered`]'s
908    // no-stream policy: there is no stream to carry `Err` items, so `Corrupt`
909    // frames — and adapter-detected data errors like `response.failed` — fail
910    // the whole operation instead of returning a silently partial completion.
911    // Buffered classification adds the envelope-repair salvage; see
912    // [`ResponsesAdapter::buffered`].
913    run_wire_buffered(frames, ResponsesAdapter::buffered(initial_usage))
914}
915
916pub(crate) async fn completion_response_from_sse_body(
917    provider: &str,
918    body: &str,
919    raw_response: CompletionResponse,
920) -> Result<completion::CompletionResponse, CompletionError> {
921    let raw_choices = raw_choices_from_sse_body(
922        body,
923        raw_response
924            .usage
925            .clone()
926            .unwrap_or_else(ResponsesUsage::new),
927    )?;
928    completion_response_from_raw_choices(provider, raw_choices, &raw_response)
929        .await?
930        .ok_or_else(|| CompletionError::ResponseError("Response contained no parts".to_owned()))
931}
932
933/// Replay accumulated raw choices through [`normalize_responses_stream`] and
934/// merge the result with the parsed terminal response body.
935///
936/// The replayed stream is authoritative where it reported something; the
937/// terminal body fills any gap it left (usage, message ID, finish reason,
938/// model). Returns `Ok(None)` when the replay produced no content, leaving the
939/// caller to decide how to fall back.
940pub(crate) async fn completion_response_from_raw_choices(
941    provider: &str,
942    raw_choices: Vec<StreamingRawChoice>,
943    raw_response: &CompletionResponse,
944) -> Result<Option<completion::CompletionResponse>, CompletionError> {
945    let stream = futures::stream::iter(
946        raw_choices
947            .into_iter()
948            .map(Ok::<_, CompletionError>)
949            .collect::<Vec<_>>(),
950    );
951    let mut stream = normalize_responses_stream(provider, Box::pin(stream));
952
953    while let Some(item) = stream.next().await {
954        item?;
955    }
956
957    if choice_is_empty(&stream.choice) {
958        return Ok(None);
959    }
960
961    // Merge per content kind: the replayed choice is authoritative for what it
962    // carried (reasoning, tool calls, streamed text), but some backends emit
963    // message text only in the terminal body while streaming other kinds as
964    // deltas. A replay with no message text takes the body's message content;
965    // everything replayed is kept.
966    let mut choice = std::mem::take(&mut stream.choice);
967    // Presence of ANY streamed text — even whitespace — means the deltas were
968    // the content channel; merging the body then would duplicate it.
969    let replay_has_message_text = choice.iter().any(|content| {
970        matches!(
971            content,
972            completion::AssistantContent::Text(text) if !text.text.is_empty()
973        )
974    });
975    if !replay_has_message_text {
976        choice.extend(
977            raw_response
978                .output
979                .iter()
980                .filter(|item| matches!(item, Output::Message(_)))
981                .cloned()
982                .flat_map(<Vec<completion::AssistantContent>>::from),
983        );
984    }
985
986    let terminal = stream.response.clone();
987    let usage = terminal
988        .as_ref()
989        .map(|terminal| terminal.usage)
990        .unwrap_or_else(|| usage_from_raw_response(raw_response));
991    let message_id = stream
992        .message_id
993        .clone()
994        .or_else(|| message_id_from_response(raw_response));
995    let finish_reason = terminal
996        .as_ref()
997        .and_then(|terminal| terminal.finish_reason.clone())
998        .or_else(|| {
999            super::map_finish_reason(
1000                &raw_response.status,
1001                raw_response.incomplete_details.as_ref(),
1002            )
1003        });
1004    let model = terminal
1005        .as_ref()
1006        .and_then(|terminal| terminal.model.clone())
1007        .or_else(|| Some(raw_response.model.clone()).filter(|model| !model.is_empty()));
1008
1009    let response_id = stream
1010        .response
1011        .as_ref()
1012        .and_then(|terminal| terminal.response_id.clone())
1013        .or_else(|| Some(raw_response.id.clone()).filter(|id| !id.is_empty()));
1014
1015    Ok(Some(
1016        completion::CompletionResponse::new(choice, usage, provider)
1017            .with_optional_message_id(message_id)
1018            .with_optional_response_id(response_id)
1019            .with_optional_model(model)
1020            .with_optional_finish_reason(finish_reason),
1021    ))
1022}
1023
1024fn choice_is_empty(choice: &[completion::AssistantContent]) -> bool {
1025    choice.iter().all(|content| match content {
1026        completion::AssistantContent::Text(text) => text.text.trim().is_empty(),
1027        completion::AssistantContent::Reasoning(reasoning) => reasoning.content.is_empty(),
1028        completion::AssistantContent::Image(_) => false,
1029        completion::AssistantContent::ToolCall(_) => false,
1030    })
1031}
1032
1033fn message_id_from_response(response: &CompletionResponse) -> Option<String> {
1034    response.output.iter().find_map(|item| match item {
1035        Output::Message(message) => Some(message.id.clone()),
1036        _ => None,
1037    })
1038}
1039
1040fn usage_from_raw_response(response: &CompletionResponse) -> completion::Usage {
1041    response
1042        .usage
1043        .as_ref()
1044        .map(completion::Usage::from)
1045        .unwrap_or_default()
1046}
1047
1048/// Open a Responses SSE stream whose terminal record stays provider-native.
1049///
1050/// Pass the result through [`normalize_responses_stream`] to obtain the
1051/// normalized stream that [`completion::CompletionModel::stream`] returns.
1052pub(crate) fn raw_stream_from_event_source<HttpClient, RequestBody>(
1053    event_source: GenericEventSource<HttpClient, RequestBody>,
1054    span: tracing::Span,
1055) -> streaming::RawStreamingResult<StreamingCompletionResponse>
1056where
1057    HttpClient: HttpClientExt + Clone + 'static,
1058    RequestBody: Into<bytes::Bytes> + Clone + WasmCompatSend + 'static,
1059{
1060    raw_stream_from_event_source_with_options(event_source, span, ResponsesStreamOptions::strict())
1061}
1062
1063pub(crate) fn raw_stream_from_event_source_with_options<HttpClient, RequestBody>(
1064    event_source: GenericEventSource<HttpClient, RequestBody>,
1065    span: tracing::Span,
1066    options: ResponsesStreamOptions,
1067) -> streaming::RawStreamingResult<StreamingCompletionResponse>
1068where
1069    HttpClient: HttpClientExt + Clone + 'static,
1070    RequestBody: Into<bytes::Bytes> + Clone + WasmCompatSend + 'static,
1071{
1072    // The wire's in-band provider `error` envelope is a terminal transport
1073    // condition, detected pre-classification exactly as an HTTP failure
1074    // would be.
1075    open_wire_stream(
1076        event_source,
1077        SseTransportOptions {
1078            open_log: OpenLog::Trace,
1079            stream_ended_is_error: false,
1080            log_transport_errors: true,
1081        },
1082        |data| {
1083            if data.trim().is_empty() || data == "[DONE]" {
1084                return FrameDisposition::Skip;
1085            }
1086            if let Some(error) = provider_response_from_responses_sse_data(&data) {
1087                // A terminal failure: the driver flushes fully-delivered
1088                // content, yields this error last, and emits no terminal
1089                // record.
1090                return FrameDisposition::Fail(error);
1091            }
1092            FrameDisposition::Frame(data)
1093        },
1094        ResponsesAdapter::live(options),
1095        span,
1096    )
1097}
1098
1099/// One classified Responses frame, carrying its raw payload alongside the
1100/// decoded chunk: `response.failed` preserves the raw event body as the
1101/// provider error body, exactly as the pre-migration loop did.
1102pub(crate) struct ResponsesFrameEvent {
1103    raw: String,
1104    chunk: StreamingCompletionChunk,
1105}
1106
1107/// The OpenAI Responses SSE wire as a [`WireAdapter`], shared by the live
1108/// loop ([`run_wire_stream`]) and the buffered unary path
1109/// ([`run_wire_buffered`]).
1110///
1111/// Holds the per-stream assembly state ([`RawChoiceAccumulator`]); frame
1112/// triage policy lives in the drivers, not here. The two modes differ only in
1113/// classification: the buffered mode adds the envelope-repair salvage for
1114/// ChatGPT's replayed bodies (see [`repair_envelope_less_frame`] for why the
1115/// live wire deliberately does NOT repair).
1116pub(crate) struct ResponsesAdapter {
1117    accumulator: RawChoiceAccumulator,
1118    options: ResponsesStreamOptions,
1119    /// Buffered-only envelope salvage; `false` on the live wire.
1120    repair_envelopes: bool,
1121    /// A `response.failed` event ended the turn: the flush-then-`Err`
1122    /// sequence has been pushed and the driver stops consuming.
1123    finished: bool,
1124}
1125
1126impl ResponsesAdapter {
1127    fn live(options: ResponsesStreamOptions) -> Self {
1128        Self {
1129            accumulator: RawChoiceAccumulator::new(ResponsesUsage::new()),
1130            options,
1131            repair_envelopes: false,
1132            finished: false,
1133        }
1134    }
1135
1136    fn buffered(initial_usage: ResponsesUsage) -> Self {
1137        Self {
1138            accumulator: RawChoiceAccumulator::new(initial_usage),
1139            options: ResponsesStreamOptions::strict(),
1140            repair_envelopes: true,
1141            finished: false,
1142        }
1143    }
1144}
1145
1146impl WireAdapter for ResponsesAdapter {
1147    type Frame = WireFrame;
1148    type Event = ResponsesFrameEvent;
1149    type Response = StreamingCompletionResponse;
1150
1151    fn classify(&self, frame: WireFrame) -> WireEvent<ResponsesFrameEvent> {
1152        let data = frame.as_str().into_owned();
1153        let event = if self.repair_envelopes {
1154            // Buffered bodies (ChatGPT's replayed unary SSE) omit envelope
1155            // bookkeeping fields; salvage through the SAME interpreter, with
1156            // the operation-error wording the buffered driver surfaces
1157            // verbatim.
1158            wire::classify_with_repair(
1159                &data,
1160                classify_responses_frame,
1161                repair_envelope_less_frame,
1162                |corrupt| {
1163                    <serde_json::Error as serde::de::Error>::custom(format!(
1164                        "invalid JSON frame in buffered Responses SSE body: {corrupt}"
1165                    ))
1166                },
1167                || {
1168                    let kind = serde_json::from_str::<serde_json::Value>(&data)
1169                        .ok()
1170                        .and_then(|value| {
1171                            value
1172                                .get("type")
1173                                .and_then(serde_json::Value::as_str)
1174                                .map(ToOwned::to_owned)
1175                        })
1176                        .unwrap_or_default();
1177                    <serde_json::Error as serde::de::Error>::custom(format!(
1178                        "malformed `{kind}` event in buffered Responses SSE body"
1179                    ))
1180                },
1181            )
1182        } else {
1183            classify_responses_frame(&data)
1184        };
1185        event.map(|chunk| ResponsesFrameEvent { raw: data, chunk })
1186    }
1187
1188    fn interpret(&mut self, event: ResponsesFrameEvent, out: &mut AdapterOutput<Self::Response>) {
1189        if self.finished {
1190            return;
1191        }
1192
1193        match event.chunk {
1194            StreamingCompletionChunk::Delta(chunk) => {
1195                out.extend(
1196                    self.accumulator
1197                        .decode_item_chunk(chunk, self.options)
1198                        .into_iter()
1199                        .map(Ok),
1200                );
1201            }
1202            StreamingCompletionChunk::Response(chunk) => {
1203                let ResponseChunk { kind, response, .. } = *chunk;
1204                if matches!(kind, ResponseChunkKind::ResponseCompleted) {
1205                    let span = tracing::Span::current();
1206                    span.record("gen_ai.response.id", response.id.as_str());
1207                    span.record("gen_ai.response.model", response.model.as_str());
1208                }
1209                if let Err(error) = self
1210                    .accumulator
1211                    .record_response_chunk(kind, response, &event.raw)
1212                {
1213                    // `response.failed`: fully-delivered tool calls flush
1214                    // before the terminal error, which ends the stream with
1215                    // no terminal record, preserving the failure signal.
1216                    out.extend(self.accumulator.take_tool_calls().into_iter().map(Ok));
1217                    out.push(Err(error));
1218                    self.finished = true;
1219                }
1220            }
1221        }
1222    }
1223
1224    fn finish(&mut self, out: &mut AdapterOutput<Self::Response>) {
1225        let accumulator = std::mem::replace(
1226            &mut self.accumulator,
1227            RawChoiceAccumulator::new(ResponsesUsage::new()),
1228        );
1229        let final_usage = accumulator.final_usage.clone();
1230
1231        // Flush buffered tool calls, then the terminal record when a genuine
1232        // terminal event arrived; EOF without one is truncation and the
1233        // accumulator withholds the record (deferral, never synthesis).
1234        out.extend(accumulator.finish().into_iter().map(Ok));
1235
1236        let span = tracing::Span::current();
1237        span.record("gen_ai.usage.input_tokens", final_usage.input_tokens);
1238        span.record("gen_ai.usage.output_tokens", final_usage.output_tokens);
1239        let cached_tokens = final_usage
1240            .input_tokens_details
1241            .as_ref()
1242            .map(|d| d.cached_tokens)
1243            .unwrap_or(0);
1244        span.record("gen_ai.usage.cache_read.input_tokens", cached_tokens);
1245    }
1246
1247    fn flush_before_terminal_error(&mut self, out: &mut AdapterOutput<Self::Response>) {
1248        // Tool calls the provider fully delivered are content: they flush
1249        // before the terminal error reaches the consumer.
1250        out.extend(self.accumulator.take_tool_calls().into_iter().map(Ok));
1251    }
1252
1253    fn is_finished(&self) -> bool {
1254        self.finished
1255    }
1256}
1257
1258/// An item message chunk from OpenAI's Responses API.
1259/// See
1260#[derive(Debug, Serialize, Deserialize, Clone)]
1261pub struct ItemChunk {
1262    /// Item ID. Optional.
1263    pub item_id: Option<String>,
1264    /// The output index of the item from a given streamed response.
1265    pub output_index: u64,
1266    /// The item type chunk, as well as the inner data.
1267    #[serde(flatten)]
1268    pub data: ItemChunkKind,
1269}
1270
1271/// The item chunk type from OpenAI's Responses API.
1272#[derive(Debug, Serialize, Deserialize, Clone)]
1273#[serde(tag = "type")]
1274pub enum ItemChunkKind {
1275    #[serde(rename = "response.output_item.added")]
1276    OutputItemAdded(StreamingItemDoneOutput),
1277    #[serde(rename = "response.output_item.done")]
1278    OutputItemDone(StreamingItemDoneOutput),
1279    #[serde(rename = "response.content_part.added")]
1280    ContentPartAdded(ContentPartChunk),
1281    #[serde(rename = "response.content_part.done")]
1282    ContentPartDone(ContentPartChunk),
1283    #[serde(rename = "response.output_text.delta")]
1284    OutputTextDelta(DeltaTextChunk),
1285    #[serde(rename = "response.output_text.done")]
1286    OutputTextDone(OutputTextChunk),
1287    #[serde(rename = "response.refusal.delta")]
1288    RefusalDelta(DeltaTextChunk),
1289    #[serde(rename = "response.refusal.done")]
1290    RefusalDone(RefusalTextChunk),
1291    #[serde(rename = "response.function_call_arguments.delta")]
1292    FunctionCallArgsDelta(DeltaTextChunkWithItemId),
1293    #[serde(rename = "response.function_call_arguments.done")]
1294    FunctionCallArgsDone(ArgsTextChunk),
1295    #[serde(rename = "response.reasoning_summary_part.added")]
1296    ReasoningSummaryPartAdded(SummaryPartChunk),
1297    #[serde(rename = "response.reasoning_summary_part.done")]
1298    ReasoningSummaryPartDone(SummaryPartChunk),
1299    #[serde(rename = "response.reasoning_summary_text.delta")]
1300    ReasoningSummaryTextDelta(SummaryTextChunk),
1301    #[serde(rename = "response.reasoning_summary_text.done")]
1302    ReasoningSummaryTextDone(SummaryTextChunk),
1303    #[serde(rename = "response.reasoning_text.delta")]
1304    ReasoningTextDelta(DeltaTextChunkWithItemId),
1305    /// Terminator for a raw-reasoning block, restating the text the
1306    /// `response.reasoning_text.delta` events already streamed.
1307    ///
1308    /// Modeled but not acted on — it falls into `decode_item_chunk`'s no-op
1309    /// arm exactly like [`Self::ReasoningSummaryTextDone`], because the
1310    /// accumulated deltas are already the authoritative content and replaying
1311    /// the restatement would double the reasoning text.
1312    ///
1313    /// The variant is what makes naming the tag in
1314    /// `is_known_responses_event_type` safe: the classify layer sends every
1315    /// KNOWN tag straight to `decode_known`, so listing the tag without a
1316    /// variant to decode into would turn today's benign warn-and-skip into an
1317    /// in-band `Corrupt`/`Err` on every raw-reasoning block (#2258 G4). The
1318    /// two edits only make sense together.
1319    #[serde(rename = "response.reasoning_text.done")]
1320    ReasoningTextDone(OutputTextChunk),
1321    // No `#[serde(other)]` catch-all: unknown event types are triaged by the
1322    // classify layer (`classify_responses_frame` checks the `type` tag against
1323    // `is_known_responses_event_type` BEFORE decoding), so a frame that
1324    // reaches this decoder with an unmodeled tag is a known-set/enum drift
1325    // and must fail loudly (`Corrupt`) rather than be silently absorbed.
1326}
1327
1328#[derive(Debug, Serialize, Deserialize, Clone)]
1329pub struct StreamingItemDoneOutput {
1330    pub sequence_number: u64,
1331    pub item: Output,
1332}
1333
1334#[derive(Debug, Serialize, Deserialize, Clone)]
1335pub struct ContentPartChunk {
1336    pub content_index: u64,
1337    pub sequence_number: u64,
1338    pub part: ContentPartChunkPart,
1339}
1340
1341#[derive(Debug, Serialize, Clone)]
1342#[serde(tag = "type", rename_all = "snake_case")]
1343pub enum ContentPartChunkPart {
1344    OutputText {
1345        text: String,
1346    },
1347    SummaryText {
1348        text: String,
1349    },
1350    /// Any part type this client doesn't model — `refusal` and
1351    /// `reasoning_text` parts appear on real refusal/reasoning-text turns,
1352    /// and new part types ship without notice. Content-part events are
1353    /// bookkeeping (the content itself arrives via the corresponding delta
1354    /// events, e.g. `response.refusal.delta`), so an unmodeled part must
1355    /// parse as a no-op rather than fail the whole chunk — the same shape as
1356    /// [`Output::Unknown`](super::Output).
1357    #[serde(untagged)]
1358    Unknown(serde_json::Value),
1359}
1360
1361/// Hand-written tag dispatch instead of a trailing `#[serde(untagged)]`
1362/// variant: on an internally-tagged enum the untagged fallback also swallows
1363/// a *known* tag with an invalid payload, silently demoting a data-level
1364/// defect to a skippable unknown part
1365/// (`rig-2257-code-review-findings-34ee8ba5.md` P2). Here a known part tag
1366/// must decode fully or error; only an unmodeled (or absent) tag falls back
1367/// to [`ContentPartChunkPart::Unknown`], preserving the value verbatim.
1368///
1369/// Two documented edges of the hand dispatch (#2258 F8):
1370/// - A part with **duplicate `type` keys** dispatches on the **last**
1371///   occurrence, because `serde_json::Value` keeps the last duplicate, while
1372///   a derived internally-tagged enum takes the first. Duplicate keys are
1373///   not something any Responses gateway emits; the divergence is accepted
1374///   and pinned by test rather than papered over with a custom map visitor.
1375/// - A **non-string `type`** is a data-level defect of the tagged shape, not
1376///   an unmodeled part kind: it errors (classifying the frame `Corrupt`)
1377///   instead of degrading to an `Unknown` no-op.
1378impl<'de> Deserialize<'de> for ContentPartChunkPart {
1379    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1380    where
1381        D: serde::Deserializer<'de>,
1382    {
1383        let value = serde_json::Value::deserialize(deserializer)?;
1384        let text_field = |part: &str| -> Result<String, D::Error> {
1385            value
1386                .get("text")
1387                .and_then(serde_json::Value::as_str)
1388                .map(ToOwned::to_owned)
1389                .ok_or_else(|| {
1390                    serde::de::Error::custom(format!(
1391                        "`{part}` content part is missing a string `text` field"
1392                    ))
1393                })
1394        };
1395        match value.get("type").cloned() {
1396            Some(serde_json::Value::String(tag)) => match tag.as_str() {
1397                "output_text" => Ok(Self::OutputText {
1398                    text: text_field("output_text")?,
1399                }),
1400                "summary_text" => Ok(Self::SummaryText {
1401                    text: text_field("summary_text")?,
1402                }),
1403                _ => Ok(Self::Unknown(value)),
1404            },
1405            Some(_) => Err(serde::de::Error::custom(
1406                "content part `type` must be a string",
1407            )),
1408            None => Ok(Self::Unknown(value)),
1409        }
1410    }
1411}
1412
1413#[derive(Debug, Serialize, Deserialize, Clone)]
1414pub struct DeltaTextChunk {
1415    pub content_index: u64,
1416    pub sequence_number: u64,
1417    pub delta: String,
1418}
1419
1420#[derive(Debug, Serialize, Deserialize, Clone)]
1421pub struct DeltaTextChunkWithItemId {
1422    #[serde(default, skip_serializing_if = "Option::is_none")]
1423    pub content_index: Option<u64>,
1424    pub sequence_number: u64,
1425    pub delta: String,
1426}
1427
1428#[derive(Debug, Serialize, Deserialize, Clone)]
1429pub struct OutputTextChunk {
1430    pub content_index: u64,
1431    pub sequence_number: u64,
1432    pub text: String,
1433}
1434
1435#[derive(Debug, Serialize, Deserialize, Clone)]
1436pub struct RefusalTextChunk {
1437    pub content_index: u64,
1438    pub sequence_number: u64,
1439    pub refusal: String,
1440}
1441
1442#[derive(Debug, Serialize, Deserialize, Clone)]
1443pub struct ArgsTextChunk {
1444    #[serde(default, skip_serializing_if = "Option::is_none")]
1445    pub content_index: Option<u64>,
1446    pub sequence_number: u64,
1447    pub arguments: serde_json::Value,
1448}
1449
1450#[derive(Debug, Serialize, Deserialize, Clone)]
1451pub struct SummaryPartChunk {
1452    pub summary_index: u64,
1453    pub sequence_number: u64,
1454    pub part: SummaryPartChunkPart,
1455}
1456
1457#[derive(Debug, Serialize, Deserialize, Clone)]
1458pub struct SummaryTextChunk {
1459    pub summary_index: u64,
1460    pub sequence_number: u64,
1461    // `response.reasoning_summary_text.delta` carries `delta`;
1462    // the `.done` sibling carries the full `text` under the same shape.
1463    #[serde(alias = "text")]
1464    pub delta: String,
1465}
1466
1467#[derive(Debug, Serialize, Deserialize, Clone)]
1468#[serde(tag = "type", rename_all = "snake_case")]
1469pub enum SummaryPartChunkPart {
1470    SummaryText { text: String },
1471}
1472
1473impl<Ext, H> GenericResponsesCompletionModel<Ext, H>
1474where
1475    crate::client::Client<Ext, H>: HttpClientExt + Clone + WasmCompatSend + 'static,
1476    Ext: crate::client::Provider + ResponsesProviderExt + Clone + 'static,
1477    H: Clone + WasmCompatSend + 'static,
1478{
1479    /// Open a stream whose terminal record stays provider-native.
1480    ///
1481    /// This is the escape hatch for Responses-API terminal fields rig does not
1482    /// normalize. It shares the request builder, transport, telemetry, and
1483    /// error handling with
1484    /// [`CompletionModel::stream`](completion::CompletionModel::stream), which
1485    /// calls it and normalizes the terminal record — one network request either
1486    /// way.
1487    pub async fn raw_stream(
1488        &self,
1489        completion_request: crate::completion::CompletionRequest,
1490    ) -> Result<streaming::RawStreamingResult<StreamingCompletionResponse>, CompletionError> {
1491        let system_instructions = completion_request.preamble.clone();
1492        let record_telemetry_content = completion_request.record_telemetry_content;
1493        let (request_model, request) = self.create_provider_request(completion_request, true)?;
1494
1495        crate::providers::internal::trace_json(
1496            crate::providers::internal::LogTarget::Completions,
1497            "Responses streaming completion request",
1498            &request,
1499        );
1500
1501        let body = serde_json::to_vec(&request)?;
1502
1503        let req = self
1504            .client
1505            .post(Ext::RESPONSES_PATH)?
1506            .body(body)
1507            .map_err(|e| CompletionError::HttpError(e.into()))?;
1508
1509        let span = CompletionSpanBuilder::new(
1510            Ext::PROVIDER_NAME,
1511            &request_model,
1512            CompletionOperation::ChatStreaming,
1513        )
1514        .system_instructions(system_instructions.as_deref(), record_telemetry_content)
1515        .build();
1516        let client = self.client.clone();
1517        let event_source = GenericEventSource::new(client, req);
1518        let (event_source, request_id_slot) = match Ext::REQUEST_ID_HEADER {
1519            Some(header) => {
1520                let (event_source, slot) = event_source.capture_request_id(header);
1521                (event_source, Some(slot))
1522            }
1523            None => (event_source, None),
1524        };
1525
1526        let options = if Ext::EMITS_COMPLETE_TOOL_CALLS_IMMEDIATELY {
1527            ResponsesStreamOptions::strict_with_immediate_tool_calls()
1528        } else {
1529            ResponsesStreamOptions::strict()
1530        };
1531        let stream = raw_stream_from_event_source_with_options(event_source, span, options);
1532        Ok(
1533            crate::providers::internal::sse_transport::stamp_terminal_request_id(
1534                stream,
1535                request_id_slot,
1536                Ext::REQUEST_ID_HEADER,
1537                |response, id| response.provider_request_id = Some(id),
1538            ),
1539        )
1540    }
1541
1542    pub(crate) async fn stream(
1543        &self,
1544        completion_request: crate::completion::CompletionRequest,
1545    ) -> Result<streaming::StreamingCompletionResponse, CompletionError> {
1546        let raw = self.raw_stream(completion_request).await?;
1547
1548        Ok(normalize_responses_stream(Ext::PROVIDER_NAME, raw))
1549    }
1550}
1551
1552#[cfg(test)]
1553mod tests {
1554    use super::{
1555        ContentPartChunkPart, ItemChunk, ItemChunkKind, RawChoiceAccumulator,
1556        ResponsesStreamOptions, StreamingCompletionChunk, classify_responses_frame,
1557        raw_choices_from_sse_body, reasoning_end_from_done_item,
1558    };
1559    use crate::completion::CompletionModel;
1560    use crate::message::ReasoningContent;
1561    use crate::providers::internal::openai_chat_completions_compatible::test_support::{
1562        sse_bytes_from_data_lines, sse_bytes_from_json_events,
1563    };
1564    use crate::providers::internal::wire::WireEvent;
1565    use crate::providers::openai::responses_api::{
1566        AdditionalParameters, CompletionResponse, IncompleteDetailsReason, OutputTokensDetails,
1567        ReasoningSummary, ResponseError, ResponseObject, ResponseStatus, ResponsesUsage,
1568    };
1569    use crate::streaming::{RawStreamingChoice, StreamedAssistantContent};
1570    use crate::test_utils::MockStreamingClient;
1571    use crate::{client::CompletionClient, providers::openai};
1572    use futures::StreamExt;
1573    use serde_json::{self, json};
1574
1575    #[test]
1576    fn classify_known_event_decodes() {
1577        let frame = json!({
1578            "type": "response.output_text.delta",
1579            "item_id": "msg_1",
1580            "output_index": 0,
1581            "content_index": 0,
1582            "sequence_number": 1,
1583            "delta": "hi",
1584        })
1585        .to_string();
1586        assert!(matches!(
1587            classify_responses_frame(&frame),
1588            WireEvent::Known(StreamingCompletionChunk::Delta(_))
1589        ));
1590    }
1591
1592    #[test]
1593    fn classify_unknown_event_type_is_unknown() {
1594        let frame = json!({
1595            "type": "response.web_search_call.searching",
1596            "output_index": 0,
1597            "sequence_number": 1,
1598        })
1599        .to_string();
1600        assert!(matches!(
1601            classify_responses_frame(&frame),
1602            WireEvent::Unknown { event_type, .. } if event_type == "response.web_search_call.searching"
1603        ));
1604    }
1605
1606    /// #2258 G4: `response.reasoning_text.done` terminates every raw-reasoning
1607    /// block on all three Responses surfaces. It used to be absent from the
1608    /// known-event set, so each block logged a spurious "unknown event" warn
1609    /// and passed through as `Unknown`.
1610    ///
1611    /// Both halves of the fix are asserted here, because either alone is a
1612    /// regression: the tag must be KNOWN (no `Unknown`), and `ItemChunkKind`
1613    /// must carry a variant for it (no `Corrupt`, which is what naming the tag
1614    /// without the variant would have produced — strictly worse than the warn).
1615    ///
1616    /// No recorded cassette contains this event; the wire shape is the
1617    /// Responses spec's, so this unit test is the pin.
1618    #[test]
1619    fn classify_reasoning_text_done_is_known_and_decodes() {
1620        let frame = json!({
1621            "type": "response.reasoning_text.done",
1622            "item_id": "rs_1",
1623            "output_index": 0,
1624            "content_index": 0,
1625            "sequence_number": 7,
1626            "text": "the model's raw chain of thought",
1627        })
1628        .to_string();
1629
1630        let event = classify_responses_frame(&frame);
1631        assert!(
1632            !matches!(event, WireEvent::Unknown { .. }),
1633            "the tag must be in the known-event set: {event:?}"
1634        );
1635        assert!(
1636            !matches!(event, WireEvent::Corrupt(_)),
1637            "a known tag with no matching ItemChunkKind variant decodes to Corrupt, which the \
1638             driver surfaces as an in-band Err — worse than the warn it replaced: {event:?}"
1639        );
1640        assert!(matches!(
1641            event,
1642            WireEvent::Known(StreamingCompletionChunk::Delta(chunk))
1643                if matches!(chunk.data, ItemChunkKind::ReasoningTextDone(_))
1644        ));
1645    }
1646
1647    /// The done event restates text the deltas already streamed, so it must be
1648    /// a no-op: replaying it would double every raw-reasoning block.
1649    #[test]
1650    fn reasoning_text_done_emits_nothing() {
1651        let mut accumulator = RawChoiceAccumulator::new(ResponsesUsage::new());
1652        let chunk: ItemChunk = serde_json::from_value(json!({
1653            "type": "response.reasoning_text.done",
1654            "item_id": "rs_1",
1655            "output_index": 0,
1656            "content_index": 0,
1657            "sequence_number": 7,
1658            "text": "the model's raw chain of thought",
1659        }))
1660        .expect("reasoning text done event should deserialize");
1661
1662        let emitted = accumulator.decode_item_chunk(chunk, ResponsesStreamOptions::strict());
1663        assert!(
1664            emitted.is_empty(),
1665            "the done restatement must not re-emit the reasoning text: {emitted:?}"
1666        );
1667    }
1668
1669    #[test]
1670    fn classify_invalid_json_is_corrupt() {
1671        assert!(matches!(
1672            classify_responses_frame("{not json"),
1673            WireEvent::Corrupt(_)
1674        ));
1675    }
1676
1677    #[test]
1678    fn classify_known_event_with_defective_payload_is_corrupt() {
1679        let frame = json!({
1680            "type": "response.output_text.delta",
1681            "item_id": "msg_1",
1682            "output_index": 0,
1683            "content_index": 0,
1684            "sequence_number": 1,
1685            "delta": 42,
1686        })
1687        .to_string();
1688        assert!(matches!(
1689            classify_responses_frame(&frame),
1690            WireEvent::Corrupt(_)
1691        ));
1692    }
1693
1694    // The P2 probe shape from `rig-2257-code-review-findings-34ee8ba5.md`: a
1695    // known part tag whose payload is schema-defective must classify as
1696    // `Corrupt`, not slide into the unknown-part catch-all.
1697    #[test]
1698    fn classify_defective_known_content_part_is_corrupt() {
1699        let frame = json!({
1700            "type": "response.content_part.added",
1701            "item_id": "msg_1",
1702            "output_index": 0,
1703            "content_index": 0,
1704            "sequence_number": 1,
1705            "part": {"type": "output_text", "text": 42},
1706        })
1707        .to_string();
1708        assert!(matches!(
1709            classify_responses_frame(&frame),
1710            WireEvent::Corrupt(_)
1711        ));
1712    }
1713
1714    #[test]
1715    fn content_part_known_tag_decodes() {
1716        let part: ContentPartChunkPart =
1717            serde_json::from_value(json!({"type": "output_text", "text": "hi"})).unwrap();
1718        assert!(matches!(part, ContentPartChunkPart::OutputText { text } if text == "hi"));
1719    }
1720
1721    #[test]
1722    fn content_part_known_tag_with_defective_payload_errors() {
1723        let result = serde_json::from_value::<ContentPartChunkPart>(
1724            json!({"type": "output_text", "text": 42}),
1725        );
1726        assert!(result.is_err());
1727        let result = serde_json::from_value::<ContentPartChunkPart>(
1728            json!({"type": "summary_text", "text": 42}),
1729        );
1730        assert!(result.is_err());
1731    }
1732
1733    // A non-string `type` is a data-level defect of the tagged shape, never a
1734    // skippable unknown part (#2258 F8).
1735    #[test]
1736    fn content_part_non_string_type_errors() {
1737        let result =
1738            serde_json::from_value::<ContentPartChunkPart>(json!({"type": 42, "text": "hi"}));
1739        assert!(result.is_err());
1740        let result =
1741            serde_json::from_value::<ContentPartChunkPart>(json!({"type": null, "text": "hi"}));
1742        assert!(result.is_err());
1743    }
1744
1745    // Pins the documented duplicate-key edge (#2258 F8): `serde_json::Value`
1746    // keeps the last duplicate key, so the hand dispatch resolves on the LAST
1747    // `type` — unlike a derived internally-tagged enum, which takes the first.
1748    #[test]
1749    fn content_part_duplicate_type_key_dispatches_on_the_last_occurrence() {
1750        let part: ContentPartChunkPart =
1751            serde_json::from_str(r#"{"type":"bogus","type":"output_text","text":"hi"}"#).unwrap();
1752        assert!(matches!(part, ContentPartChunkPart::OutputText { text } if text == "hi"));
1753    }
1754
1755    // `refusal` and `reasoning_text` part tags are not in the modeled set:
1756    // they must stay skippable no-ops (the content arrives via the
1757    // corresponding delta events), round-tripping the value verbatim.
1758    #[test]
1759    fn content_part_unknown_tag_is_preserved_verbatim() {
1760        let wire = json!({"type": "refusal", "refusal": "no"});
1761        let part: ContentPartChunkPart = serde_json::from_value(wire.clone()).unwrap();
1762        let ContentPartChunkPart::Unknown(value) = &part else {
1763            panic!("unmodeled part tag must fall back to Unknown");
1764        };
1765        assert_eq!(value, &wire);
1766        assert_eq!(serde_json::to_value(&part).unwrap(), wire);
1767    }
1768
1769    fn sample_response(status: ResponseStatus) -> CompletionResponse {
1770        CompletionResponse {
1771            id: "resp_123".to_string(),
1772            object: ResponseObject::Response,
1773            provider_request_id: None,
1774            created_at: 0,
1775            status,
1776            error: None,
1777            incomplete_details: None,
1778            instructions: None,
1779            max_output_tokens: None,
1780            model: "gpt-5.4".to_string(),
1781            provider_reasoning: None,
1782            reasoning_metadata: None,
1783            reasoning_context: None,
1784            usage: None,
1785            output: Vec::new(),
1786            tools: Vec::new(),
1787            additional_parameters: AdditionalParameters::default(),
1788        }
1789    }
1790
1791    async fn first_error_from_event(
1792        event: serde_json::Value,
1793    ) -> crate::completion::CompletionError {
1794        let client = openai::Client::builder()
1795            .http_client(MockStreamingClient {
1796                sse_bytes: sse_bytes_from_json_events(&[event]),
1797            })
1798            .api_key("test-key")
1799            .build()
1800            .expect("client should build");
1801        let model = client.completion_model("gpt-5.4");
1802        let request = model.completion_request("hello").build();
1803        let mut stream = model.stream(request).await.expect("stream should start");
1804
1805        stream
1806            .next()
1807            .await
1808            .expect("stream should yield an item")
1809            .expect_err("stream should surface a provider error")
1810    }
1811
1812    /// The provider-native terminal record, as `raw_stream` exposes it.
1813    async fn final_response_from_event(
1814        event: serde_json::Value,
1815    ) -> super::StreamingCompletionResponse {
1816        let client = openai::Client::builder()
1817            .http_client(MockStreamingClient {
1818                sse_bytes: sse_bytes_from_json_events(&[event]),
1819            })
1820            .api_key("test-key")
1821            .build()
1822            .expect("client should build");
1823        let model = client.completion_model("gpt-5.4");
1824        let request = model.completion_request("hello").build();
1825        let mut stream = model
1826            .raw_stream(request)
1827            .await
1828            .expect("stream should start");
1829
1830        while let Some(item) = stream.next().await {
1831            if let RawStreamingChoice::FinalResponse(response) =
1832                item.expect("completed stream should not error")
1833            {
1834                return response;
1835            }
1836        }
1837
1838        panic!("stream should yield a final response");
1839    }
1840
1841    /// The normalized terminal record, as `stream` exposes it.
1842    async fn stream_final_from_event(event: serde_json::Value) -> crate::streaming::StreamFinal {
1843        let client = openai::Client::builder()
1844            .http_client(MockStreamingClient {
1845                sse_bytes: sse_bytes_from_json_events(&[event]),
1846            })
1847            .api_key("test-key")
1848            .build()
1849            .expect("client should build");
1850        let model = client.completion_model("gpt-5.4");
1851        let request = model.completion_request("hello").build();
1852        let mut stream = model.stream(request).await.expect("stream should start");
1853
1854        while let Some(item) = stream.next().await {
1855            if let StreamedAssistantContent::Final(response) =
1856                item.expect("completed stream should not error")
1857            {
1858                return response;
1859            }
1860        }
1861
1862        panic!("stream should yield a final response");
1863    }
1864
1865    #[test]
1866    fn parse_sse_completion_body_preserves_error_payloads() {
1867        let mut response = sample_response(ResponseStatus::Failed);
1868        response.error = Some(ResponseError {
1869            code: "server_error".to_string(),
1870            message: "response failed".to_string(),
1871        });
1872        let events = [
1873            json!({
1874                "type": "response.failed",
1875                "sequence_number": 1,
1876                "response": response,
1877            }),
1878            json!({
1879                "type": "error",
1880                "error": {
1881                    "message": "boom",
1882                    "code": "server_error",
1883                    "type": "server_error"
1884                }
1885            }),
1886        ];
1887
1888        for event in events {
1889            let payload = serde_json::to_string(&event).expect("event should serialize");
1890            let body = format!("data: {payload}\n");
1891            let err = super::parse_sse_completion_body(&body, "ChatGPT")
1892                .expect_err("error payload should surface as provider response");
1893
1894            assert!(matches!(
1895                err,
1896                crate::completion::CompletionError::ProviderResponse(_)
1897            ));
1898            assert_eq!(err.provider_response_status(), None);
1899            assert_eq!(err.provider_response_body(), Some(payload.as_str()));
1900        }
1901    }
1902
1903    #[test]
1904    fn reasoning_done_item_fuses_summary_content_and_encrypted_into_one_end() {
1905        let summary = vec![
1906            ReasoningSummary::SummaryText {
1907                text: "step 1".to_string(),
1908            },
1909            ReasoningSummary::SummaryText {
1910                text: "step 2".to_string(),
1911            },
1912        ];
1913        let content = vec!["private reasoning".to_string()];
1914        let end = reasoning_end_from_done_item(
1915            &crate::streaming::StreamPartId::wire("rs_1"),
1916            crate::streaming::WireId::new("rs_1").as_ref(),
1917            summary,
1918            content,
1919            Some("enc_blob".to_string()),
1920        );
1921
1922        // ONE end event carrying every block in wire field order — never a
1923        // choice per block, which made siblings under one `rs_*` id.
1924        let Some(RawStreamingChoice::ReasoningEnd {
1925            id,
1926            reasoning: Some(reasoning),
1927            signature: None,
1928            wire_sent: true,
1929        }) = end
1930        else {
1931            panic!("expected one wire-sent ReasoningEnd restatement");
1932        };
1933        assert_eq!(id, crate::streaming::StreamPartId::wire("rs_1"));
1934        assert_eq!(reasoning.id.as_deref(), Some("rs_1"));
1935        assert_eq!(
1936            reasoning.content,
1937            vec![
1938                ReasoningContent::Summary("step 1".to_string()),
1939                ReasoningContent::Summary("step 2".to_string()),
1940                ReasoningContent::Text {
1941                    text: "private reasoning".to_string(),
1942                    signature: None,
1943                },
1944                ReasoningContent::Encrypted("enc_blob".to_string()),
1945            ]
1946        );
1947    }
1948
1949    #[test]
1950    fn reasoning_output_item_done_emits_reasoning_text_content() {
1951        let body = format!(
1952            "data: {}\n",
1953            json!({
1954                "type": "response.output_item.done",
1955                "output_index": 0,
1956                "sequence_number": 1,
1957                "item": {
1958                    "type": "reasoning",
1959                    "id": "rs_text_1",
1960                    "summary": [],
1961                    "content": [{ "type": "reasoning_text", "text": "visible reasoning" }],
1962                    "status": "completed"
1963                },
1964            })
1965        );
1966
1967        let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
1968            .expect("sse body should decode");
1969
1970        // The done item arrives as one wire-sent end restatement whose
1971        // single block is the reasoning text.
1972        assert!(matches!(
1973            choices.first(),
1974            Some(RawStreamingChoice::ReasoningEnd {
1975                id,
1976                reasoning: Some(reasoning),
1977                wire_sent: true,
1978                ..
1979            }) if id == &crate::streaming::StreamPartId::wire("rs_text_1")
1980                && reasoning.content
1981                    == vec![ReasoningContent::Text {
1982                        text: "visible reasoning".to_string(),
1983                        signature: None,
1984                    }]
1985        ));
1986    }
1987
1988    /// Envelope-less replay shape (ChatGPT bodies): an id-less summary
1989    /// delta mints an Output-kind key, the done item restates the whole
1990    /// block under the SAME adopted minted key, and visible text follows.
1991    /// The driver's boundary law must treat the same-key whole block as a
1992    /// close — this exact body used to abort every debug build
1993    /// (sequence-law O1, Responses variant).
1994    #[test]
1995    fn envelope_less_reasoning_then_text_decodes_without_violation() {
1996        let body = format!(
1997            "data: {}\ndata: {}\ndata: {}\n",
1998            json!({
1999                "type": "response.reasoning_summary_text.delta",
2000                "output_index": 0,
2001                "summary_index": 0,
2002                "sequence_number": 1,
2003                "delta": "thinking",
2004            }),
2005            json!({
2006                "type": "response.output_item.done",
2007                "output_index": 0,
2008                "sequence_number": 2,
2009                "item": {
2010                    "type": "reasoning",
2011                    "id": "",
2012                    "summary": [{ "type": "summary_text", "text": "thinking, complete" }],
2013                    "status": "completed"
2014                },
2015            }),
2016            json!({
2017                "type": "response.output_text.delta",
2018                "item_id": "msg_1",
2019                "output_index": 1,
2020                "content_index": 0,
2021                "sequence_number": 3,
2022                "delta": "the answer",
2023            }),
2024        );
2025
2026        let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
2027            .expect("sse body should decode without a sequence-law violation");
2028        assert!(choices.iter().any(
2029            |choice| matches!(choice, RawStreamingChoice::Message(text) if text == "the answer")
2030        ));
2031    }
2032
2033    #[test]
2034    fn reasoning_text_delta_emits_reasoning_delta() {
2035        let body = format!(
2036            "data: {}\n",
2037            json!({
2038                "type": "response.reasoning_text.delta",
2039                "item_id": "rs_delta_1",
2040                "output_index": 0,
2041                "content_index": 0,
2042                "sequence_number": 1,
2043                "delta": "thinking",
2044            })
2045        );
2046
2047        let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
2048            .expect("sse body should decode");
2049
2050        assert!(matches!(
2051            choices.first(),
2052            Some(RawStreamingChoice::ReasoningDelta { id, provider_id: _, reasoning })
2053                if id == &crate::streaming::StreamPartId::wire("rs_delta_1") && reasoning == "thinking"
2054        ));
2055    }
2056
2057    #[test]
2058    fn unknown_output_item_surfaces_as_raw_unknown_choice() {
2059        // A hosted-tool item (web_search_call) arriving on
2060        // `response.output_item.done` must surface to stream consumers as
2061        // `RawStreamingChoice::Unknown` carrying the verbatim item, mirroring how
2062        // the non-streaming decode preserves it on `CompletionResponse.output`.
2063        let item = json!({
2064            "type": "web_search_call",
2065            "id": "ws_001",
2066            "status": "completed",
2067            "action": { "type": "search", "queries": ["rig framework"] },
2068        });
2069        let body = format!(
2070            "data: {}\n",
2071            json!({
2072                "type": "response.output_item.done",
2073                "output_index": 0,
2074                "sequence_number": 1,
2075                "item": item,
2076            })
2077        );
2078
2079        let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
2080            .expect("sse body should decode");
2081
2082        let unknown = choices.iter().find_map(|choice| match choice {
2083            RawStreamingChoice::Unknown(value) => Some(value),
2084            _ => None,
2085        });
2086        assert_eq!(
2087            unknown,
2088            Some(&item.clone().into()),
2089            "the raw web_search_call item should reach the consumer verbatim",
2090        );
2091    }
2092
2093    #[test]
2094    fn reasoning_done_item_without_encrypted_emits_summary_only() {
2095        let summary = vec![ReasoningSummary::SummaryText {
2096            text: "only summary".to_string(),
2097        }];
2098        let end = reasoning_end_from_done_item(
2099            &crate::streaming::StreamPartId::wire("rs_2"),
2100            crate::streaming::WireId::new("rs_2").as_ref(),
2101            summary,
2102            Vec::new(),
2103            None,
2104        );
2105
2106        let Some(RawStreamingChoice::ReasoningEnd {
2107            id,
2108            reasoning: Some(reasoning),
2109            ..
2110        }) = end
2111        else {
2112            panic!("expected one ReasoningEnd restatement");
2113        };
2114        assert_eq!(id, crate::streaming::StreamPartId::wire("rs_2"));
2115        assert_eq!(
2116            reasoning.content,
2117            vec![ReasoningContent::Summary("only summary".to_string())]
2118        );
2119    }
2120
2121    #[test]
2122    fn empty_encrypted_reasoning_is_not_emitted() {
2123        let content = vec!["visible reasoning".to_string()];
2124
2125        let end = reasoning_end_from_done_item(
2126            &crate::streaming::StreamPartId::wire("rs_1"),
2127            crate::streaming::WireId::new("rs_1").as_ref(),
2128            Vec::new(),
2129            content,
2130            Some(String::new()),
2131        );
2132
2133        let Some(RawStreamingChoice::ReasoningEnd {
2134            reasoning: Some(reasoning),
2135            ..
2136        }) = end
2137        else {
2138            panic!("expected one ReasoningEnd restatement");
2139        };
2140        assert_eq!(
2141            reasoning.content,
2142            vec![ReasoningContent::Text {
2143                text: "visible reasoning".to_string(),
2144                signature: None,
2145            }],
2146            "an empty encrypted payload contributes no block"
2147        );
2148
2149        // An entirely empty done item says nothing at the boundary.
2150        assert!(
2151            reasoning_end_from_done_item(
2152                &crate::streaming::StreamPartId::wire("rs_1"),
2153                crate::streaming::WireId::new("rs_1").as_ref(),
2154                Vec::new(),
2155                Vec::new(),
2156                Some(String::new()),
2157            )
2158            .is_none()
2159        );
2160    }
2161
2162    #[test]
2163    fn content_part_added_deserializes_snake_case_part_type() {
2164        let chunk: StreamingCompletionChunk = serde_json::from_value(json!({
2165            "type": "response.content_part.added",
2166            "item_id": "msg_1",
2167            "output_index": 0,
2168            "content_index": 0,
2169            "sequence_number": 3,
2170            "part": {
2171                "type": "output_text",
2172                "text": "hello"
2173            }
2174        }))
2175        .expect("content part event should deserialize");
2176
2177        assert!(matches!(
2178            chunk,
2179            StreamingCompletionChunk::Delta(chunk)
2180                if matches!(
2181                    chunk.data,
2182                    ItemChunkKind::ContentPartAdded(_)
2183                )
2184        ));
2185    }
2186
2187    #[test]
2188    fn content_part_done_deserializes_snake_case_part_type() {
2189        let chunk: StreamingCompletionChunk = serde_json::from_value(json!({
2190            "type": "response.content_part.done",
2191            "item_id": "msg_1",
2192            "output_index": 0,
2193            "content_index": 0,
2194            "sequence_number": 4,
2195            "part": {
2196                "type": "summary_text",
2197                "text": "done"
2198            }
2199        }))
2200        .expect("content part done event should deserialize");
2201
2202        assert!(matches!(
2203            chunk,
2204            StreamingCompletionChunk::Delta(chunk)
2205                if matches!(
2206                    chunk.data,
2207                    ItemChunkKind::ContentPartDone(_)
2208                )
2209        ));
2210    }
2211
2212    #[test]
2213    fn reasoning_summary_part_added_deserializes_snake_case_part_type() {
2214        let chunk: StreamingCompletionChunk = serde_json::from_value(json!({
2215            "type": "response.reasoning_summary_part.added",
2216            "item_id": "rs_1",
2217            "output_index": 0,
2218            "summary_index": 0,
2219            "sequence_number": 5,
2220            "part": {
2221                "type": "summary_text",
2222                "text": "step 1"
2223            }
2224        }))
2225        .expect("reasoning summary part event should deserialize");
2226
2227        assert!(matches!(
2228            chunk,
2229            StreamingCompletionChunk::Delta(chunk)
2230                if matches!(
2231                    chunk.data,
2232                    ItemChunkKind::ReasoningSummaryPartAdded(_)
2233                )
2234        ));
2235    }
2236
2237    #[test]
2238    fn reasoning_summary_part_done_deserializes_snake_case_part_type() {
2239        let chunk: StreamingCompletionChunk = serde_json::from_value(json!({
2240            "type": "response.reasoning_summary_part.done",
2241            "item_id": "rs_1",
2242            "output_index": 0,
2243            "summary_index": 0,
2244            "sequence_number": 6,
2245            "part": {
2246                "type": "summary_text",
2247                "text": "step 2"
2248            }
2249        }))
2250        .expect("reasoning summary part done event should deserialize");
2251
2252        assert!(matches!(
2253            chunk,
2254            StreamingCompletionChunk::Delta(chunk)
2255                if matches!(
2256                    chunk.data,
2257                    ItemChunkKind::ReasoningSummaryPartDone(_)
2258                )
2259        ));
2260    }
2261
2262    #[tokio::test]
2263    async fn response_failed_chunk_surfaces_provider_error_without_empty_code_prefix() {
2264        let mut response = sample_response(ResponseStatus::Failed);
2265        response.error = Some(ResponseError {
2266            code: String::new(),
2267            message: "maximum context length exceeded".to_string(),
2268        });
2269
2270        let event = json!({
2271            "type": "response.failed",
2272            "sequence_number": 1,
2273            "response": response,
2274        });
2275
2276        let err = first_error_from_event(event).await;
2277
2278        assert!(matches!(
2279            err,
2280            crate::completion::CompletionError::ProviderResponse(_)
2281        ));
2282        assert_eq!(err.provider_response_status(), None);
2283        assert!(err.provider_response_body().is_some_and(|body| {
2284            body.contains("response.failed") && body.contains("maximum context length exceeded")
2285        }));
2286    }
2287
2288    #[tokio::test]
2289    async fn response_failed_chunk_surfaces_provider_error_with_code_prefix() {
2290        let mut response = sample_response(ResponseStatus::Failed);
2291        response.error = Some(ResponseError {
2292            code: "context_length_exceeded".to_string(),
2293            message: "maximum context length exceeded".to_string(),
2294        });
2295
2296        let event = json!({
2297            "type": "response.failed",
2298            "sequence_number": 1,
2299            "response": response,
2300        });
2301
2302        let err = first_error_from_event(event).await;
2303
2304        assert!(matches!(
2305            err,
2306            crate::completion::CompletionError::ProviderResponse(_)
2307        ));
2308        assert_eq!(err.provider_response_status(), None);
2309        assert!(err.provider_response_body().is_some_and(|body| {
2310            body.contains("response.failed")
2311                && body.contains("context_length_exceeded")
2312                && body.contains("maximum context length exceeded")
2313        }));
2314    }
2315
2316    #[tokio::test]
2317    async fn response_incomplete_chunk_is_a_successful_terminal_with_mapped_finish_reason() {
2318        let text_delta = json!({
2319            "type": "response.output_text.delta",
2320            "content_index": 0,
2321            "delta": "partial",
2322            "item_id": "msg_incomplete_1",
2323            "output_index": 0,
2324            "sequence_number": 1,
2325        });
2326
2327        let mut response = sample_response(ResponseStatus::Incomplete);
2328        response.incomplete_details = Some(IncompleteDetailsReason {
2329            reason: "max_output_tokens".to_string(),
2330        });
2331        response.usage = Some(ResponsesUsage {
2332            input_tokens: 10,
2333            input_tokens_details: None,
2334            output_tokens: 5,
2335            output_tokens_details: Some(OutputTokensDetails {
2336                reasoning_tokens: 0,
2337            }),
2338            total_tokens: 15,
2339        });
2340
2341        let incomplete = json!({
2342            "type": "response.incomplete",
2343            "sequence_number": 2,
2344            "response": response,
2345        });
2346
2347        let client = openai::Client::builder()
2348            .http_client(MockStreamingClient {
2349                sse_bytes: sse_bytes_from_json_events(&[text_delta, incomplete]),
2350            })
2351            .api_key("test-key")
2352            .build()
2353            .expect("client should build");
2354        let model = client.completion_model("gpt-5.4");
2355        let request = model.completion_request("hello").build();
2356        let mut stream = model.stream(request).await.expect("stream should start");
2357
2358        let mut text = String::new();
2359        let mut final_response = None;
2360        while let Some(item) = stream.next().await {
2361            match item.expect("incomplete stream should not error") {
2362                StreamedAssistantContent::Text(delta) => text.push_str(&delta.text),
2363                StreamedAssistantContent::Final(response) => final_response = Some(response),
2364                _ => {}
2365            }
2366        }
2367
2368        // The partial output survives, and the terminal record maps the
2369        // incomplete status to the same finish reason as the unary path.
2370        assert_eq!(text, "partial");
2371        let final_response = final_response.expect("stream should yield a final response");
2372        assert_eq!(
2373            final_response.finish_reason,
2374            Some(crate::completion::FinishReason::Length)
2375        );
2376        assert_eq!(final_response.usage.input_tokens, 10);
2377        assert_eq!(final_response.usage.output_tokens, 5);
2378        assert_eq!(final_response.usage.total_tokens, 15);
2379    }
2380
2381    /// A multi-block reasoning done item (summaries + `encrypted_content`)
2382    /// aggregates as exactly ONE reasoning part carrying every block in wire
2383    /// order — never sibling parts sharing one `rs_*` id, which would replay
2384    /// as duplicate reasoning input items carrying the identical id on the
2385    /// next request.
2386    #[tokio::test]
2387    async fn multi_block_reasoning_done_item_yields_one_part() {
2388        let reasoning_done = json!({
2389            "type": "response.output_item.done",
2390            "output_index": 0,
2391            "sequence_number": 1,
2392            "item": {
2393                "type": "reasoning",
2394                "id": "rs_1",
2395                "summary": [
2396                    {"type": "summary_text", "text": "step 1"},
2397                    {"type": "summary_text", "text": "step 2"}
2398                ],
2399                "content": [],
2400                "encrypted_content": "enc_blob"
2401            }
2402        });
2403        let completed = json!({
2404            "type": "response.completed",
2405            "sequence_number": 2,
2406            "response": sample_response(ResponseStatus::Completed),
2407        });
2408
2409        let client = openai::Client::builder()
2410            .http_client(MockStreamingClient {
2411                sse_bytes: sse_bytes_from_json_events(&[reasoning_done, completed]),
2412            })
2413            .api_key("test-key")
2414            .build()
2415            .expect("client should build");
2416        let model = client.completion_model("gpt-5.4");
2417        let request = model.completion_request("hello").build();
2418        let mut stream = model.stream(request).await.expect("stream should start");
2419
2420        let mut completed_reasoning = Vec::new();
2421        while let Some(item) = stream.next().await {
2422            if let StreamedAssistantContent::Reasoning { reasoning, .. } =
2423                item.expect("stream items should be ok")
2424            {
2425                completed_reasoning.push(reasoning);
2426            }
2427        }
2428
2429        assert_eq!(
2430            completed_reasoning.len(),
2431            1,
2432            "one done item must complete exactly one reasoning part, got {completed_reasoning:?}"
2433        );
2434        let reasoning = completed_reasoning.first().expect("one part");
2435        assert_eq!(reasoning.id.as_deref(), Some("rs_1"));
2436        assert_eq!(
2437            reasoning.content,
2438            vec![
2439                ReasoningContent::Summary("step 1".to_string()),
2440                ReasoningContent::Summary("step 2".to_string()),
2441                ReasoningContent::Encrypted("enc_blob".to_string()),
2442            ],
2443            "every block survives, in wire order, inside the one part"
2444        );
2445
2446        // The aggregated choice replays as exactly one reasoning input item.
2447        let choice = stream.choice;
2448        let reasoning_parts = choice
2449            .iter()
2450            .filter(|content| matches!(content, crate::message::AssistantContent::Reasoning(_)))
2451            .count();
2452        assert_eq!(
2453            reasoning_parts, 1,
2454            "history must carry one reasoning part per rs_* id, got {choice:?}"
2455        );
2456    }
2457
2458    /// A `response.failed` after a fully-delivered tool call: the tool call is
2459    /// content and flushes first, the terminal error follows, and nothing
2460    /// (least of all a terminal record) comes after it.
2461    #[tokio::test]
2462    async fn response_failed_flushes_delivered_tool_calls_before_the_error() {
2463        let tool_call_done = json!({
2464            "type": "response.output_item.done",
2465            "output_index": 0,
2466            "sequence_number": 1,
2467            "item": {
2468                "type": "function_call",
2469                "id": "fc_123",
2470                "arguments": "{}",
2471                "call_id": "call_123",
2472                "name": "example_tool",
2473                "status": "completed"
2474            }
2475        });
2476
2477        let mut response = sample_response(ResponseStatus::Failed);
2478        response.error = Some(ResponseError {
2479            code: "server_error".to_string(),
2480            message: "response stream failed".to_string(),
2481        });
2482
2483        let failed = json!({
2484            "type": "response.failed",
2485            "sequence_number": 2,
2486            "response": response,
2487        });
2488
2489        let client = openai::Client::builder()
2490            .http_client(MockStreamingClient {
2491                sse_bytes: sse_bytes_from_json_events(&[tool_call_done, failed]),
2492            })
2493            .api_key("test-key")
2494            .build()
2495            .expect("client should build");
2496        let model = client.completion_model("gpt-5.4");
2497        let request = model.completion_request("hello").build();
2498        let mut stream = model.stream(request).await.expect("stream should start");
2499
2500        let tool_call = match stream
2501            .next()
2502            .await
2503            .expect("stream should yield the flushed tool call")
2504            .expect("the flushed tool call must precede the terminal error")
2505        {
2506            StreamedAssistantContent::ToolCall { tool_call, .. } => tool_call,
2507            other => panic!("expected the flushed tool call first, got {other:?}"),
2508        };
2509        // The correlator drives rig's id; the item id rides on `provider`.
2510        assert_eq!(tool_call.id, "call_123");
2511        let provider = tool_call.provider.as_ref().expect("provider ids are kept");
2512        assert_eq!(provider.call_id, "call_123");
2513        assert_eq!(provider.item_id.as_deref(), Some("fc_123"));
2514        assert_eq!(tool_call.function.name, "example_tool");
2515
2516        let err = stream
2517            .next()
2518            .await
2519            .expect("stream should yield an item")
2520            .expect_err("stream should surface a provider error");
2521        assert!(matches!(
2522            err,
2523            crate::completion::CompletionError::ProviderResponse(_)
2524        ));
2525        assert_eq!(err.provider_response_status(), None);
2526        assert!(err.provider_response_body().is_some_and(|body| {
2527            body.contains("response.failed") && body.contains("response stream failed")
2528        }));
2529        assert!(
2530            stream.next().await.is_none(),
2531            "stream should terminate immediately after the terminal error"
2532        );
2533        assert!(stream.response.is_none());
2534    }
2535
2536    /// Same ordering for a transport failure: fully-delivered tool call, then
2537    /// the error, then the end — with no terminal record.
2538    #[tokio::test]
2539    async fn transport_error_flushes_delivered_tool_calls_before_the_error() {
2540        use crate::http_client::sse::GenericEventSource;
2541        use crate::test_utils::SequencedStreamingHttpClient;
2542
2543        let tool_call_done = json!({
2544            "type": "response.output_item.done",
2545            "output_index": 0,
2546            "sequence_number": 1,
2547            "item": {
2548                "type": "function_call",
2549                "id": "fc_123",
2550                "arguments": "{}",
2551                "call_id": "call_123",
2552                "name": "example_tool",
2553                "status": "completed"
2554            }
2555        });
2556        let chunks = vec![
2557            Ok(sse_bytes_from_data_lines([tool_call_done.to_string()])),
2558            Err(crate::http_client::Error::InvalidStatusCodeWithMessage(
2559                http::StatusCode::BAD_GATEWAY,
2560                r#"{"error":{"message":"upstream unavailable"}}"#.to_string(),
2561            )),
2562        ];
2563        let client = SequencedStreamingHttpClient::new(chunks);
2564        let req = http::Request::builder()
2565            .method("POST")
2566            .uri("http://localhost/v1/responses")
2567            .body(Vec::new())
2568            .expect("request should build");
2569        let event_source = GenericEventSource::new(client, req);
2570        let mut stream = super::normalize_responses_stream(
2571            "openai",
2572            super::raw_stream_from_event_source(event_source, tracing::Span::none()),
2573        );
2574
2575        match stream
2576            .next()
2577            .await
2578            .expect("stream should yield the flushed tool call")
2579            .expect("the flushed tool call must precede the transport error")
2580        {
2581            StreamedAssistantContent::ToolCall { tool_call, .. } => {
2582                assert_eq!(tool_call.id, "call_123");
2583                let provider = tool_call.provider.as_ref().expect("provider ids are kept");
2584                assert_eq!(provider.item_id.as_deref(), Some("fc_123"));
2585            }
2586            other => panic!("expected the flushed tool call first, got {other:?}"),
2587        }
2588
2589        let err = stream
2590            .next()
2591            .await
2592            .expect("stream should yield the transport error")
2593            .expect_err("the transport failure must reach the consumer");
2594        assert_eq!(
2595            err.provider_response_status(),
2596            Some(http::StatusCode::BAD_GATEWAY)
2597        );
2598
2599        assert!(
2600            stream.next().await.is_none(),
2601            "nothing may follow the terminal error"
2602        );
2603        assert!(stream.response.is_none());
2604    }
2605
2606    /// A known terminal event with a data-level defect (malformed `usage`) is
2607    /// a corrupt frame, not silent truncation: the error surfaces and, since
2608    /// the terminal itself failed to parse, no terminal record is emitted.
2609    #[tokio::test]
2610    async fn known_terminal_with_malformed_usage_surfaces_error_without_terminal() {
2611        let mut event = json!({
2612            "type": "response.completed",
2613            "sequence_number": 1,
2614            "response": sample_response(ResponseStatus::Completed),
2615        });
2616        event["response"]["usage"] = json!("banana");
2617
2618        let client = openai::Client::builder()
2619            .http_client(MockStreamingClient {
2620                sse_bytes: sse_bytes_from_json_events(&[event]),
2621            })
2622            .api_key("test-key")
2623            .build()
2624            .expect("client should build");
2625        let model = client.completion_model("gpt-5.4");
2626        let request = model.completion_request("hello").build();
2627        let mut stream = model.stream(request).await.expect("stream should start");
2628
2629        let mut saw_error = false;
2630        let mut saw_final = false;
2631        while let Some(item) = stream.next().await {
2632            match item {
2633                Ok(StreamedAssistantContent::Final(_)) => saw_final = true,
2634                Ok(other) => panic!("unexpected stream item: {other:?}"),
2635                Err(err) => {
2636                    assert!(
2637                        matches!(err, crate::completion::CompletionError::JsonError(_)),
2638                        "expected a parse error item, got {err:?}"
2639                    );
2640                    saw_error = true;
2641                }
2642            }
2643        }
2644
2645        assert!(saw_error, "the corrupt terminal must surface as an error");
2646        assert!(
2647            !saw_final,
2648            "a terminal that failed to parse must not produce a terminal record"
2649        );
2650        assert!(stream.response.is_none());
2651    }
2652
2653    /// An invented event type stays skippable for forward compatibility; a
2654    /// later genuine terminal still completes the stream.
2655    #[tokio::test]
2656    async fn unknown_event_type_is_skipped_and_stream_completes() {
2657        let unknown = json!({
2658            "type": "response.rocket_launch",
2659            "payload": { "count": 3 }
2660        });
2661        let completed = json!({
2662            "type": "response.completed",
2663            "sequence_number": 2,
2664            "response": sample_response(ResponseStatus::Completed),
2665        });
2666
2667        let client = openai::Client::builder()
2668            .http_client(MockStreamingClient {
2669                sse_bytes: sse_bytes_from_json_events(&[unknown, completed]),
2670            })
2671            .api_key("test-key")
2672            .build()
2673            .expect("client should build");
2674        let model = client.completion_model("gpt-5.4");
2675        let request = model.completion_request("hello").build();
2676        let mut stream = model.stream(request).await.expect("stream should start");
2677
2678        let mut saw_final = false;
2679        while let Some(item) = stream.next().await {
2680            if let StreamedAssistantContent::Final(_) =
2681                item.expect("unknown event types must not surface as errors")
2682            {
2683                saw_final = true;
2684            }
2685        }
2686        assert!(
2687            saw_final,
2688            "the genuine terminal must still complete the stream"
2689        );
2690    }
2691
2692    #[tokio::test]
2693    async fn refusal_content_part_frames_are_no_ops_and_refusal_text_streams() {
2694        // A refusal turn emits `response.content_part.added/.done` with a
2695        // `refusal` part — a shape outside the modeled text parts — followed
2696        // by the refusal text via `response.refusal.delta`. The part frames
2697        // must parse as no-ops (never error items); the deltas carry the
2698        // content.
2699        let part_added = json!({
2700            "type": "response.content_part.added",
2701            "item_id": "msg_1",
2702            "output_index": 0,
2703            "content_index": 0,
2704            "sequence_number": 1,
2705            "part": { "type": "refusal", "refusal": "" }
2706        });
2707        let refusal_delta = json!({
2708            "type": "response.refusal.delta",
2709            "item_id": "msg_1",
2710            "output_index": 0,
2711            "content_index": 0,
2712            "sequence_number": 2,
2713            "delta": "I can't help with that."
2714        });
2715        let part_done = json!({
2716            "type": "response.content_part.done",
2717            "item_id": "msg_1",
2718            "output_index": 0,
2719            "content_index": 0,
2720            "sequence_number": 3,
2721            "part": { "type": "refusal", "refusal": "I can't help with that." }
2722        });
2723        let reasoning_part = json!({
2724            "type": "response.content_part.added",
2725            "item_id": "rs_1",
2726            "output_index": 1,
2727            "content_index": 0,
2728            "sequence_number": 4,
2729            "part": { "type": "reasoning_text", "text": "" }
2730        });
2731        let completed = json!({
2732            "type": "response.completed",
2733            "sequence_number": 5,
2734            "response": sample_response(ResponseStatus::Completed),
2735        });
2736
2737        let client = openai::Client::builder()
2738            .http_client(MockStreamingClient {
2739                sse_bytes: sse_bytes_from_json_events(&[
2740                    part_added,
2741                    refusal_delta,
2742                    part_done,
2743                    reasoning_part,
2744                    completed,
2745                ]),
2746            })
2747            .api_key("test-key")
2748            .build()
2749            .expect("client should build");
2750        let model = client.completion_model("gpt-5.4");
2751        let request = model.completion_request("hello").build();
2752        let mut stream = model.stream(request).await.expect("stream should start");
2753
2754        let mut texts = Vec::new();
2755        let mut saw_final = false;
2756        while let Some(item) = stream.next().await {
2757            match item.expect("content-part frames must not surface as errors") {
2758                StreamedAssistantContent::Text(text) => texts.push(text.text),
2759                StreamedAssistantContent::Final(_) => saw_final = true,
2760                _ => {}
2761            }
2762        }
2763
2764        assert_eq!(texts, ["I can't help with that."]);
2765        assert!(saw_final, "the terminal must still arrive");
2766    }
2767
2768    #[tokio::test]
2769    async fn truncated_stream_does_not_synthesize_a_terminal_record() {
2770        use crate::providers::internal::openai_chat_completions_compatible::test_support::sse_bytes_from_json_events;
2771        use crate::test_utils::MockStreamingClient;
2772
2773        // Deltas then EOF without `response.completed`: the accumulator's
2774        // `saw_terminal` gate must withhold the terminal record rather than
2775        // present the truncated turn as a successful completion.
2776        let deltas = [
2777            json!({
2778                "type": "response.output_text.delta",
2779                "output_index": 0,
2780                "content_index": 0,
2781                "sequence_number": 1,
2782                "delta": "hel"
2783            }),
2784            json!({
2785                "type": "response.output_text.delta",
2786                "output_index": 0,
2787                "content_index": 0,
2788                "sequence_number": 2,
2789                "delta": "lo"
2790            }),
2791        ];
2792
2793        let client = openai::Client::builder()
2794            .http_client(MockStreamingClient {
2795                sse_bytes: sse_bytes_from_json_events(&deltas),
2796            })
2797            .api_key("test-key")
2798            .build()
2799            .expect("client should build");
2800        let model = client.completion_model("gpt-5.4");
2801        let request = model.completion_request("hello").build();
2802        let mut stream = model.stream(request).await.expect("stream should start");
2803
2804        let mut texts = Vec::new();
2805        let mut saw_terminal = false;
2806        while let Some(item) = stream.next().await {
2807            match item.expect("stream item should be Ok") {
2808                StreamedAssistantContent::Text(text) => texts.push(text.text),
2809                StreamedAssistantContent::Final(_) => saw_terminal = true,
2810                _ => {}
2811            }
2812        }
2813
2814        assert_eq!(texts, ["hel", "lo"]);
2815        assert!(
2816            !saw_terminal,
2817            "EOF without response.completed must not synthesize a terminal record"
2818        );
2819        assert!(stream.response.is_none());
2820    }
2821
2822    #[tokio::test]
2823    async fn streaming_error_event_preserves_full_payload_in_live_loop() {
2824        use crate::providers::internal::openai_chat_completions_compatible::test_support::sse_bytes_from_json_events;
2825        use crate::test_utils::MockStreamingClient;
2826
2827        let payload = json!({
2828            "type": "error",
2829            "error": {
2830                "message": "boom",
2831                "code": "server_error",
2832                "type": "server_error"
2833            }
2834        });
2835
2836        let client = openai::Client::builder()
2837            .http_client(MockStreamingClient {
2838                sse_bytes: sse_bytes_from_json_events(&[payload]),
2839            })
2840            .api_key("test-key")
2841            .build()
2842            .expect("client should build");
2843        let model = client.completion_model("gpt-5.4");
2844        let request = model.completion_request("hello").build();
2845        let mut stream = model.stream(request).await.expect("stream should start");
2846
2847        let err = stream
2848            .next()
2849            .await
2850            .expect("stream should yield an item")
2851            .expect_err("stream should surface a provider response error");
2852        assert_eq!(err.provider_response_status(), None);
2853        assert!(
2854            err.provider_response_body().is_some_and(|body| {
2855                body.contains("\"type\":\"error\"") && body.contains("boom")
2856            })
2857        );
2858        assert!(
2859            stream.next().await.is_none(),
2860            "stream should terminate after error event"
2861        );
2862    }
2863
2864    #[tokio::test]
2865    async fn streaming_http_non_success_preserves_status_and_body() {
2866        use crate::http_client::sse::GenericEventSource;
2867        use crate::test_utils::HttpErrorStreamingClient;
2868
2869        let body = r#"{"error":{"message":"quota exceeded"}}"#;
2870        let client = HttpErrorStreamingClient::new(http::StatusCode::TOO_MANY_REQUESTS, body);
2871        let req = http::Request::builder()
2872            .method("POST")
2873            .uri("http://localhost/v1/responses")
2874            .body(Vec::new())
2875            .expect("request should build");
2876        let event_source = GenericEventSource::new(client, req);
2877        let span = tracing::Span::none();
2878        let mut stream = super::normalize_responses_stream(
2879            "openai",
2880            super::raw_stream_from_event_source(event_source, span),
2881        );
2882
2883        let err = stream
2884            .next()
2885            .await
2886            .expect("stream should yield transport error")
2887            .expect_err("HTTP non-success should surface as a stream error");
2888        assert_eq!(
2889            err.provider_response_status(),
2890            Some(http::StatusCode::TOO_MANY_REQUESTS)
2891        );
2892        assert_eq!(err.provider_response_body(), Some(body));
2893        assert_eq!(
2894            err.provider_response_json().expect("valid JSON body"),
2895            Some(serde_json::json!({"error": {"message": "quota exceeded"}}))
2896        );
2897        assert!(
2898            stream.next().await.is_none(),
2899            "stream should terminate after HTTP non-success"
2900        );
2901    }
2902
2903    /// The buffered unary path has no stream to carry error items, so a
2904    /// corrupt known frame fails the whole decode — even when a valid terminal
2905    /// follows — instead of returning a silently partial completion.
2906    #[test]
2907    fn corrupt_known_frame_fails_the_buffered_body() {
2908        let corrupt = json!({
2909            "type": "response.output_text.delta",
2910            "delta": 42
2911        });
2912        let completed = json!({
2913            "type": "response.completed",
2914            "sequence_number": 2,
2915            "response": sample_response(ResponseStatus::Completed),
2916        });
2917        let body = format!("data: {corrupt}\ndata: {completed}\n");
2918
2919        let err = raw_choices_from_sse_body(&body, ResponsesUsage::new())
2920            .expect_err("a corrupt known frame must fail the buffered decode");
2921        assert!(
2922            err.to_string().contains("response.output_text.delta"),
2923            "the error should name the malformed event, got: {err}"
2924        );
2925
2926        // Syntactically invalid JSON fails too.
2927        let body = format!("data: {{not json\ndata: {completed}\n");
2928        raw_choices_from_sse_body(&body, ResponsesUsage::new())
2929            .expect_err("invalid JSON must fail the buffered decode");
2930
2931        // Unknown event types stay skippable.
2932        let unknown = json!({ "type": "response.rocket_launch", "count": 3 });
2933        let body = format!("data: {unknown}\ndata: {completed}\n");
2934        let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
2935            .expect("unknown event types must stay skippable");
2936        assert!(
2937            choices
2938                .iter()
2939                .any(|choice| matches!(choice, RawStreamingChoice::FinalResponse(_))),
2940            "the genuine terminal must still be recorded"
2941        );
2942    }
2943
2944    /// Envelope-less frames (ChatGPT's replayed bodies) are repaired and fed
2945    /// through the same typed interpreter as the live loop, so the buffered
2946    /// path agrees with the live path's semantics.
2947    #[test]
2948    fn envelope_less_frames_repair_onto_the_shared_interpreter() {
2949        let completed = json!({
2950            "type": "response.completed",
2951            "response": sample_response(ResponseStatus::Completed),
2952        });
2953
2954        // A ChatGPT-style text delta with no envelope bookkeeping fields.
2955        let body = format!(
2956            "data: {}\ndata: {completed}\n",
2957            json!({ "type": "response.output_text.delta", "delta": "hi" })
2958        );
2959        let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
2960            .expect("an envelope-less delta must repair and decode");
2961        assert!(
2962            choices
2963                .iter()
2964                .any(|choice| matches!(choice, RawStreamingChoice::Message(text) if text == "hi"))
2965        );
2966
2967        // Live-path parity, pinned: a function-call-arguments delta with no
2968        // `item_id` is keyed by the minted slot identity (the repair injects
2969        // `output_index: 0`), matching the live loop — it must flow into
2970        // assembly instead of vanishing.
2971        let body = format!(
2972            "data: {}\ndata: {completed}\n",
2973            json!({ "type": "response.function_call_arguments.delta", "delta": "{}" })
2974        );
2975        let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
2976            .expect("an id-less args delta must repair and decode");
2977        assert!(choices.iter().any(|choice| matches!(
2978            choice,
2979            RawStreamingChoice::ToolCallDelta { id, .. } if id == &crate::streaming::MintKind::Output.for_wire_index(0)
2980        )));
2981
2982        // Live-path parity, pinned: an envelope-less bookkeeping event whose
2983        // data is intact (`.done` events) is a no-op, not an error as the old
2984        // salvage made it.
2985        let body = format!(
2986            "data: {}\ndata: {completed}\n",
2987            json!({ "type": "response.output_text.done", "text": "hi" })
2988        );
2989        let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
2990            .expect("an envelope-less done event must repair to the live no-op");
2991        assert!(
2992            choices
2993                .iter()
2994                .any(|choice| matches!(choice, RawStreamingChoice::FinalResponse(_)))
2995        );
2996
2997        // An envelope-less reasoning summary delta keys by the repaired
2998        // `output_index`, matching the live derivation.
2999        let body = format!(
3000            "data: {}\ndata: {completed}\n",
3001            json!({ "type": "response.reasoning_summary_text.delta", "delta": "think" })
3002        );
3003        let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
3004            .expect("an envelope-less summary delta must repair and decode");
3005        assert!(choices.iter().any(|choice| matches!(
3006            choice,
3007            RawStreamingChoice::ReasoningDelta { id, provider_id: _, reasoning }
3008                if id == &crate::streaming::MintKind::Output.for_wire_index(0) && reasoning == "think"
3009        )));
3010    }
3011
3012    /// The `max_output_tokens`-mid-tool-call shape: `arguments_delta`
3013    /// frames stream partial JSON and the done item restates the same
3014    /// truncated bytes (unparseable). Re-emitting the restatement as
3015    /// another raw delta put the partial JSON in the buffer TWICE — a
3016    /// delta-reassembling consumer rendered it twice and the bytes were
3017    /// double-charged against the accumulation bound. Fragments seen →
3018    /// the buffer already holds the bytes; only a fragment-less done item
3019    /// (pure replay of a truncated restatement) still routes its raw
3020    /// string through the buffer at all (#2258 P3).
3021    #[test]
3022    fn an_unparseable_restatement_is_not_reemitted_over_streamed_fragments() {
3023        let delta = json!({
3024            "type": "response.function_call_arguments.delta",
3025            "item_id": "fc_1",
3026            "output_index": 0,
3027            "sequence_number": 1,
3028            "delta": "{\"x\":481",
3029        });
3030        let done = json!({
3031            "type": "response.output_item.done",
3032            "output_index": 0,
3033            "sequence_number": 2,
3034            "item": {
3035                "type": "function_call",
3036                "id": "fc_1",
3037                "call_id": "call_1",
3038                "name": "add",
3039                "arguments": "{\"x\":481",
3040                "status": "incomplete"
3041            },
3042        });
3043        let body = format!(
3044            "data: {delta}
3045data: {done}
3046"
3047        );
3048
3049        let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
3050            .expect("the truncated shape must decode");
3051        let raw_fragments: Vec<&str> = choices
3052            .iter()
3053            .filter_map(|choice| match choice {
3054                RawStreamingChoice::ToolCallDelta {
3055                    content: crate::streaming::ToolCallDeltaContent::Delta(fragment),
3056                    ..
3057                } => Some(fragment.as_str()),
3058                _ => None,
3059            })
3060            .collect();
3061        assert_eq!(
3062            raw_fragments,
3063            vec!["{\"x\":481"],
3064            "the streamed fragment is buffered once; the restatement adds nothing"
3065        );
3066    }
3067
3068    /// The pure-replay half of the same policy: a truncated restatement
3069    /// with NO preceding fragments must still reach the buffer (else the
3070    /// bytes never arrive and the truncation policy has nothing to judge).
3071    #[test]
3072    fn a_fragmentless_unparseable_restatement_still_reaches_the_buffer() {
3073        let done = json!({
3074            "type": "response.output_item.done",
3075            "output_index": 0,
3076            "sequence_number": 1,
3077            "item": {
3078                "type": "function_call",
3079                "id": "fc_1",
3080                "call_id": "call_1",
3081                "name": "add",
3082                "arguments": "{\"x\":481",
3083                "status": "incomplete"
3084            },
3085        });
3086        let body = format!(
3087            "data: {done}
3088"
3089        );
3090
3091        let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
3092            .expect("the replayed truncated shape must decode");
3093        let raw_fragments = choices
3094            .iter()
3095            .filter(|choice| {
3096                matches!(
3097                    choice,
3098                    RawStreamingChoice::ToolCallDelta {
3099                        content: crate::streaming::ToolCallDeltaContent::Delta(_),
3100                        ..
3101                    }
3102                )
3103            })
3104            .count();
3105        assert_eq!(raw_fragments, 1, "the raw bytes must reach the buffer once");
3106    }
3107
3108    /// A slot mixing id-bearing and id-less reasoning frames (gateways and
3109    /// ChatGPT's envelope-less replay bodies omit the id on a subset of a
3110    /// slot's events) must key every frame — and the done item — by ONE
3111    /// slot identity, the same discipline `tool_slots` applies. Per-event
3112    /// resolution split the slot into `Wire("rs_1")` and `Minted(Output, 0)`,
3113    /// and the done item superseded only one of them: the other survived as
3114    /// an orphaned partial part carrying the same provider id.
3115    #[tokio::test]
3116    async fn mixed_id_and_id_less_reasoning_frames_share_one_slot_key() {
3117        let with_id = json!({
3118            "type": "response.reasoning_summary_text.delta",
3119            "item_id": "rs_1",
3120            "output_index": 0,
3121            "summary_index": 0,
3122            "sequence_number": 1,
3123            "delta": "s1 ",
3124        });
3125        let id_less = json!({
3126            "type": "response.reasoning_summary_text.delta",
3127            "output_index": 0,
3128            "summary_index": 0,
3129            "sequence_number": 2,
3130            "delta": "s2",
3131        });
3132        let done = json!({
3133            "type": "response.output_item.done",
3134            "output_index": 0,
3135            "sequence_number": 3,
3136            "item": {
3137                "type": "reasoning",
3138                "id": "rs_1",
3139                "summary": [{"type": "summary_text", "text": "s1 s2"}],
3140                "content": [],
3141                "status": "completed",
3142            },
3143        });
3144        let completed = json!({
3145            "type": "response.completed",
3146            "response": sample_response(ResponseStatus::Completed),
3147        });
3148        let body = format!("data: {with_id}\ndata: {id_less}\ndata: {done}\ndata: {completed}\n");
3149
3150        let raw_choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
3151            .expect("the mixed slot must decode");
3152        let mut keys = std::collections::HashSet::new();
3153        for choice in &raw_choices {
3154            match choice {
3155                RawStreamingChoice::ReasoningDelta { id, .. } => {
3156                    keys.insert(id.clone());
3157                }
3158                RawStreamingChoice::ReasoningEnd { id, .. } => {
3159                    keys.insert(id.clone());
3160                }
3161                _ => {}
3162            }
3163        }
3164        assert_eq!(
3165            keys.len(),
3166            1,
3167            "one slot, one assembly key — got {keys:?} across {raw_choices:?}"
3168        );
3169
3170        let raw_response = sample_response(ResponseStatus::Completed);
3171        let response =
3172            super::completion_response_from_raw_choices("openai", raw_choices, &raw_response)
3173                .await
3174                .expect("the mixed slot should normalize")
3175                .expect("a reasoning-bearing stream is not empty");
3176        let reasoning_parts = response
3177            .choice
3178            .iter()
3179            .filter(|content| matches!(content, crate::completion::AssistantContent::Reasoning(_)))
3180            .count();
3181        assert_eq!(
3182            reasoning_parts, 1,
3183            "the done item supersedes the one delta-built part; nothing orphans"
3184        );
3185    }
3186
3187    /// #2258 F3: an id-less reasoning delta is keyed by the minted
3188    /// `output-{index}` identity, and the slot's `output_item.done` full block
3189    /// (which always carries the real `rs_*` id) must adopt that minted
3190    /// identity — otherwise the restated summary appends beside the
3191    /// delta-built part and duplicates it. This is the ChatGPT envelope-less
3192    /// replay shape: the repair injects `output_index: 0` into the delta while
3193    /// the done item arrives envelope-full.
3194    #[tokio::test]
3195    async fn envelope_less_reasoning_deltas_are_superseded_by_their_done_item() {
3196        let delta = json!({ "type": "response.reasoning_summary_text.delta", "delta": "think" });
3197        let done = json!({
3198            "type": "response.output_item.done",
3199            "output_index": 0,
3200            "sequence_number": 2,
3201            "item": {
3202                "type": "reasoning",
3203                "id": "rs_1",
3204                "summary": [{"type": "summary_text", "text": "think"}],
3205                "content": [],
3206                "status": "completed",
3207            },
3208        });
3209        let completed = json!({
3210            "type": "response.completed",
3211            "response": sample_response(ResponseStatus::Completed),
3212        });
3213        let body = format!("data: {delta}\ndata: {done}\ndata: {completed}\n");
3214
3215        let raw_choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
3216            .expect("the envelope-less reasoning replay must decode");
3217        // The done item's restatement shares the minted per-slot identity.
3218        assert!(raw_choices.iter().any(|choice| matches!(
3219            choice,
3220            RawStreamingChoice::ReasoningEnd { id, reasoning: Some(_), .. }
3221                if id == &crate::streaming::MintKind::Output.for_wire_index(0)
3222        )));
3223
3224        let raw_response = sample_response(ResponseStatus::Completed);
3225        let response =
3226            super::completion_response_from_raw_choices("chatgpt", raw_choices, &raw_response)
3227                .await
3228                .expect("replay should normalize")
3229                .expect("a reasoning-bearing replay is not empty");
3230
3231        let reasoning: Vec<_> = response
3232            .choice
3233            .iter()
3234            .filter_map(|content| match content {
3235                crate::completion::AssistantContent::Reasoning(reasoning) => Some(reasoning),
3236                _ => None,
3237            })
3238            .collect();
3239        assert_eq!(
3240            reasoning.len(),
3241            1,
3242            "deltas and their full block must collapse to one reasoning item: {reasoning:?}"
3243        );
3244        let occurrences = reasoning
3245            .iter()
3246            .flat_map(|item| item.content.iter())
3247            .filter(|content| match content {
3248                ReasoningContent::Summary(text) | ReasoningContent::Text { text, .. } => {
3249                    text.contains("think")
3250                }
3251                _ => false,
3252            })
3253            .count();
3254        assert_eq!(
3255            occurrences, 1,
3256            "the restated summary must supersede its deltas, not duplicate them"
3257        );
3258    }
3259
3260    /// #2258 P2: text deltas for one message item interleaved with reasoning
3261    /// must aggregate as ONE text part. Interleaving reasoning closes the open
3262    /// text block downstream, so the adapter must re-emit `TextStart` with the
3263    /// same item id when the item's text resumes — the accumulator's keyed
3264    /// reactivation then reopens the block instead of minting a sibling.
3265    #[tokio::test]
3266    async fn same_item_text_resumes_as_one_part_across_interleaved_reasoning() {
3267        let events = [
3268            json!({
3269                "type": "response.output_text.delta",
3270                "item_id": "msg_1",
3271                "output_index": 0,
3272                "content_index": 0,
3273                "sequence_number": 1,
3274                "delta": "hello "
3275            }),
3276            json!({
3277                "type": "response.reasoning_summary_text.delta",
3278                "item_id": "rs_2",
3279                "output_index": 1,
3280                "summary_index": 0,
3281                "sequence_number": 2,
3282                "delta": "because"
3283            }),
3284            json!({
3285                "type": "response.output_text.delta",
3286                "item_id": "msg_1",
3287                "output_index": 0,
3288                "content_index": 0,
3289                "sequence_number": 3,
3290                "delta": "world"
3291            }),
3292            json!({
3293                "type": "response.completed",
3294                "sequence_number": 4,
3295                "response": sample_response(ResponseStatus::Completed),
3296            }),
3297        ];
3298        let body = events
3299            .iter()
3300            .map(|event| format!("data: {event}\n"))
3301            .collect::<String>();
3302
3303        let raw_choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
3304            .expect("the interleaved stream must decode");
3305        // The resumed item re-announces its block: two `TextStart { msg_1 }`.
3306        let starts = raw_choices
3307            .iter()
3308            .filter(|choice| {
3309                matches!(
3310                    choice,
3311                    RawStreamingChoice::TextStart { id, .. } if id == &crate::streaming::StreamPartId::wire("msg_1")
3312                )
3313            })
3314            .count();
3315        assert_eq!(
3316            starts, 2,
3317            "returning to the same item must re-emit its TextStart: {raw_choices:?}"
3318        );
3319
3320        let raw_response = sample_response(ResponseStatus::Completed);
3321        let response =
3322            super::completion_response_from_raw_choices("openai", raw_choices, &raw_response)
3323                .await
3324                .expect("replay should normalize")
3325                .expect("a text-bearing replay is not empty");
3326        let texts: Vec<_> = response
3327            .choice
3328            .iter()
3329            .filter_map(|content| match content {
3330                crate::completion::AssistantContent::Text(text) => Some(text.text.clone()),
3331                _ => None,
3332            })
3333            .collect();
3334        assert_eq!(
3335            texts,
3336            ["hello world"],
3337            "same-item text must aggregate as one part around the reasoning"
3338        );
3339        assert!(
3340            response.choice.iter().any(|content| matches!(
3341                content,
3342                crate::completion::AssistantContent::Reasoning(_)
3343            )),
3344            "the interleaved reasoning must survive"
3345        );
3346    }
3347
3348    /// #2258 P3: two parallel function calls whose events all lack `fc_*` ids
3349    /// must not share the `""` assembly key — each slot gets a minted
3350    /// `output-{index}` identity shared by its added/delta/done events, so two
3351    /// distinct calls assemble.
3352    /// A slot whose `added` event carries a real `fc_*` id but whose later
3353    /// args delta arrives id-less must keep ONE assembly key: slot-scoped
3354    /// identity (the bridge) makes event-scoped key-splitting
3355    /// unrepresentable, and the finalized call reports the wire id.
3356    #[tokio::test]
3357    async fn mixed_id_and_id_less_events_share_one_slot_key() {
3358        let events = [
3359            json!({
3360                "type": "response.output_item.added",
3361                "output_index": 0,
3362                "sequence_number": 1,
3363                "item": {
3364                    "type": "function_call",
3365                    "id": "fc_real",
3366                    "call_id": "call_a",
3367                    "name": "tool_a",
3368                    "arguments": "",
3369                    "status": "in_progress",
3370                },
3371            }),
3372            // Id-less delta for the same slot: must resolve to the slot's
3373            // established key, not mint a second identity.
3374            json!({
3375                "type": "response.function_call_arguments.delta",
3376                "output_index": 0,
3377                "sequence_number": 2,
3378                "delta": "{\"x\":1}"
3379            }),
3380            json!({
3381                "type": "response.output_item.done",
3382                "output_index": 0,
3383                "sequence_number": 3,
3384                "item": {
3385                    "type": "function_call",
3386                    "id": "fc_real",
3387                    "call_id": "call_a",
3388                    "name": "tool_a",
3389                    "arguments": "{\"x\":1}",
3390                    "status": "completed",
3391                },
3392            }),
3393            json!({
3394                "type": "response.completed",
3395                "sequence_number": 4,
3396                "response": sample_response(ResponseStatus::Completed),
3397            }),
3398        ];
3399        let body = events
3400            .iter()
3401            .map(|event| format!("data: {event}\n"))
3402            .collect::<String>();
3403
3404        let raw_choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
3405            .expect("the mixed-id stream must decode");
3406
3407        // Every tool event (name delta, args delta, input end) carries the
3408        // slot's single key — no fragment dangles under a second identity.
3409        let mut keys: Vec<crate::streaming::StreamPartId> = raw_choices
3410            .iter()
3411            .filter_map(|choice| match choice {
3412                RawStreamingChoice::ToolCallDelta { id, .. } => Some(id.clone()),
3413                RawStreamingChoice::ToolInputEnd(end) => Some(end.id.clone()),
3414                _ => None,
3415            })
3416            .collect();
3417        keys.dedup();
3418        assert_eq!(
3419            keys,
3420            [crate::streaming::StreamPartId::wire("fc_real")],
3421            "one slot, one assembly key"
3422        );
3423
3424        let raw_response = sample_response(ResponseStatus::Completed);
3425        let response =
3426            super::completion_response_from_raw_choices("openai", raw_choices, &raw_response)
3427                .await
3428                .expect("replay should normalize")
3429                .expect("a tool-bearing replay is not empty");
3430        let call = response
3431            .choice
3432            .iter()
3433            .find_map(|content| match content {
3434                crate::completion::AssistantContent::ToolCall(call) => Some(call.clone()),
3435                _ => None,
3436            })
3437            .expect("the call finalizes");
3438        assert_eq!(call.function.name, "tool_a");
3439        assert_eq!(call.function.arguments, serde_json::json!({"x": 1}));
3440    }
3441
3442    #[tokio::test]
3443    async fn parallel_id_less_function_calls_assemble_distinctly() {
3444        let call_item = |name: &str, call_id: &str, arguments: &str| {
3445            json!({
3446                "type": "function_call",
3447                "call_id": call_id,
3448                "name": name,
3449                "arguments": arguments,
3450                "status": "completed",
3451            })
3452        };
3453        let events = [
3454            json!({
3455                "type": "response.output_item.added",
3456                "output_index": 0,
3457                "sequence_number": 1,
3458                "item": call_item("tool_a", "call_a", ""),
3459            }),
3460            json!({
3461                "type": "response.output_item.added",
3462                "output_index": 1,
3463                "sequence_number": 2,
3464                "item": call_item("tool_b", "call_b", ""),
3465            }),
3466            json!({
3467                "type": "response.function_call_arguments.delta",
3468                "output_index": 0,
3469                "sequence_number": 3,
3470                "delta": "{\"x\":1}"
3471            }),
3472            json!({
3473                "type": "response.function_call_arguments.delta",
3474                "output_index": 1,
3475                "sequence_number": 4,
3476                "delta": "{\"y\":2}"
3477            }),
3478            json!({
3479                "type": "response.output_item.done",
3480                "output_index": 0,
3481                "sequence_number": 5,
3482                "item": call_item("tool_a", "call_a", "{\"x\":1}"),
3483            }),
3484            json!({
3485                "type": "response.output_item.done",
3486                "output_index": 1,
3487                "sequence_number": 6,
3488                "item": call_item("tool_b", "call_b", "{\"y\":2}"),
3489            }),
3490            json!({
3491                "type": "response.completed",
3492                "sequence_number": 7,
3493                "response": sample_response(ResponseStatus::Completed),
3494            }),
3495        ];
3496        let body = events
3497            .iter()
3498            .map(|event| format!("data: {event}\n"))
3499            .collect::<String>();
3500
3501        let raw_choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
3502            .expect("the id-less parallel-call stream must decode");
3503        let raw_response = sample_response(ResponseStatus::Completed);
3504        let response =
3505            super::completion_response_from_raw_choices("openai", raw_choices, &raw_response)
3506                .await
3507                .expect("replay should normalize")
3508                .expect("a tool-bearing replay is not empty");
3509
3510        let mut calls: Vec<_> = response
3511            .choice
3512            .iter()
3513            .filter_map(|content| match content {
3514                crate::completion::AssistantContent::ToolCall(call) => Some((
3515                    call.function.name.clone(),
3516                    call.function.arguments.to_string(),
3517                )),
3518                _ => None,
3519            })
3520            .collect();
3521        calls.sort();
3522        assert_eq!(
3523            calls,
3524            [
3525                ("tool_a".to_owned(), json!({"x": 1}).to_string()),
3526                ("tool_b".to_owned(), json!({"y": 2}).to_string()),
3527            ],
3528            "each id-less slot must assemble its own call"
3529        );
3530    }
3531
3532    /// A lost `output_item.done` frame followed by a healthy
3533    /// `response.completed` must not discard the call as truncation: the
3534    /// provider proved the turn ended, so the still-open slot closes at the
3535    /// terminal and finalizes from its streamed fragments — with the full
3536    /// dual-wire identity the added event announced. The same
3537    /// terminal-drain the sibling adapters ship (Interactions at
3538    /// `interaction.completed`, chat-compat at `finish_reason`).
3539    #[tokio::test]
3540    async fn a_lost_done_frame_does_not_discard_a_provider_completed_call() {
3541        let events = [
3542            json!({
3543                "type": "response.output_item.added",
3544                "output_index": 0,
3545                "sequence_number": 1,
3546                "item": {
3547                    "type": "function_call",
3548                    "id": "fc_1",
3549                    "call_id": "call_abc",
3550                    "name": "get_weather",
3551                    "arguments": "",
3552                    "status": "in_progress",
3553                },
3554            }),
3555            json!({
3556                "type": "response.function_call_arguments.delta",
3557                "output_index": 0,
3558                "sequence_number": 2,
3559                "delta": "{\"city\":\"Paris\"}"
3560            }),
3561            // The output_item.done frame is lost; the terminal still arrives.
3562            json!({
3563                "type": "response.completed",
3564                "sequence_number": 3,
3565                "response": sample_response(ResponseStatus::Completed),
3566            }),
3567        ];
3568        let body = events
3569            .iter()
3570            .map(|event| format!("data: {event}\n"))
3571            .collect::<String>();
3572
3573        let raw_choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
3574            .expect("the stream must decode");
3575        let raw_response = sample_response(ResponseStatus::Completed);
3576        let response =
3577            super::completion_response_from_raw_choices("openai", raw_choices, &raw_response)
3578                .await
3579                .expect("replay should normalize")
3580                .expect("a tool-bearing replay is not empty");
3581
3582        let calls: Vec<_> = response
3583            .choice
3584            .iter()
3585            .filter_map(|content| match content {
3586                crate::completion::AssistantContent::ToolCall(call) => Some(call),
3587                _ => None,
3588            })
3589            .collect();
3590        assert_eq!(calls.len(), 1, "the provider-completed call must survive");
3591        let call = calls[0];
3592        assert_eq!(call.function.name, "get_weather");
3593        assert_eq!(call.function.arguments, json!({"city": "Paris"}));
3594        let provider = call.provider.as_ref().expect("the wire issued ids");
3595        assert_eq!(provider.call_id, "call_abc");
3596        assert_eq!(provider.item_id.as_deref(), Some("fc_1"));
3597    }
3598
3599    /// #2258 P3: id-less argument fragments must surface as deltas (keyed by
3600    /// the minted slot identity) rather than vanish; when the stream truncates
3601    /// before the authoritative `output_item.done` restatement, the settled
3602    /// truncation policy still applies — partial arguments never fabricate a
3603    /// call.
3604    #[tokio::test]
3605    async fn id_less_args_deltas_surface_and_truncation_fabricates_no_call() {
3606        let events = [
3607            json!({
3608                "type": "response.output_item.added",
3609                "output_index": 0,
3610                "sequence_number": 1,
3611                "item": {
3612                    "type": "function_call",
3613                    "call_id": "call_a",
3614                    "name": "tool_a",
3615                    "arguments": "",
3616                    "status": "in_progress",
3617                },
3618            }),
3619            json!({
3620                "type": "response.function_call_arguments.delta",
3621                "output_index": 0,
3622                "sequence_number": 2,
3623                "delta": "{\"loc\":"
3624            }),
3625        ];
3626        let body = events
3627            .iter()
3628            .map(|event| format!("data: {event}\n"))
3629            .collect::<String>();
3630
3631        let raw_choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
3632            .expect("the truncated id-less stream must decode");
3633        // The fragment flowed into assembly under the minted identity.
3634        assert!(
3635            raw_choices.iter().any(|choice| matches!(
3636                choice,
3637                RawStreamingChoice::ToolCallDelta {
3638                    id,
3639                    content: crate::streaming::ToolCallDeltaContent::Delta(delta),
3640                } if id == &crate::streaming::MintKind::Output.for_wire_index(0) && delta == "{\"loc\":"
3641            )),
3642            "the id-less args fragment must surface as a delta: {raw_choices:?}"
3643        );
3644
3645        // No done restatement arrived: the truncation policy withholds the
3646        // call rather than fabricating one from partial arguments.
3647        let raw_response = sample_response(ResponseStatus::Completed);
3648        let response =
3649            super::completion_response_from_raw_choices("openai", raw_choices, &raw_response)
3650                .await
3651                .expect("replay should normalize");
3652        assert!(
3653            response.is_none(),
3654            "partial arguments must not fabricate a call: {response:?}"
3655        );
3656    }
3657
3658    #[test]
3659    fn refusal_content_part_frames_do_not_fail_the_buffered_body() {
3660        // The ChatGPT buffered route replays recorded SSE bodies; a refusal
3661        // turn's `content_part` frames (an unmodeled `refusal` part) must not
3662        // fail the whole completion — the refusal text arrives via the
3663        // modeled `response.refusal.delta`.
3664        let part_added = json!({
3665            "type": "response.content_part.added",
3666            "item_id": "msg_1",
3667            "output_index": 0,
3668            "content_index": 0,
3669            "sequence_number": 1,
3670            "part": { "type": "refusal", "refusal": "" }
3671        });
3672        let refusal_delta = json!({
3673            "type": "response.refusal.delta",
3674            "item_id": "msg_1",
3675            "output_index": 0,
3676            "content_index": 0,
3677            "sequence_number": 2,
3678            "delta": "no"
3679        });
3680        let completed = json!({
3681            "type": "response.completed",
3682            "sequence_number": 3,
3683            "response": sample_response(ResponseStatus::Completed),
3684        });
3685        let body = format!(
3686            "data: {part_added}
3687data: {refusal_delta}
3688data: {completed}
3689"
3690        );
3691
3692        let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
3693            .expect("refusal content-part frames must not fail the buffered decode");
3694        assert!(
3695            choices
3696                .iter()
3697                .any(|choice| matches!(choice, RawStreamingChoice::Message(text) if text == "no")),
3698            "the refusal text must be delivered"
3699        );
3700    }
3701
3702    /// The replayed choice keeps its reasoning/tool calls, but message text
3703    /// present only in the terminal body's `output` must merge in when no text
3704    /// deltas were streamed (websocket replays hit exactly this quadrant).
3705    #[tokio::test]
3706    async fn terminal_body_message_text_merges_into_reasoning_only_replay() {
3707        use crate::providers::openai::responses_api::Output;
3708
3709        let raw_choices = vec![RawStreamingChoice::ReasoningDelta {
3710            provider_id: crate::streaming::WireId::new("rs_1"),
3711            id: crate::streaming::StreamPartId::wire("rs_1"),
3712            reasoning: "thinking".to_string(),
3713        }];
3714
3715        let mut raw_response = sample_response(ResponseStatus::Completed);
3716        raw_response.output = vec![
3717            serde_json::from_value::<Output>(json!({
3718                "type": "message",
3719                "id": "msg_body_1",
3720                "status": "completed",
3721                "role": "assistant",
3722                "content": [{ "type": "output_text", "annotations": [], "text": "full answer" }]
3723            }))
3724            .expect("output message should deserialize"),
3725        ];
3726
3727        let response =
3728            super::completion_response_from_raw_choices("openai", raw_choices, &raw_response)
3729                .await
3730                .expect("replay should normalize")
3731                .expect("a reasoning-bearing replay is not empty");
3732
3733        let text: String = response
3734            .choice
3735            .iter()
3736            .filter_map(|content| match content {
3737                crate::completion::AssistantContent::Text(text) => Some(text.text.as_str()),
3738                _ => None,
3739            })
3740            .collect();
3741        assert_eq!(text, "full answer");
3742        assert!(
3743            response.choice.iter().any(|content| matches!(
3744                content,
3745                crate::completion::AssistantContent::Reasoning(_)
3746            )),
3747            "the replayed reasoning must be kept"
3748        );
3749        assert_eq!(response.message_id.as_deref(), Some("msg_body_1"));
3750    }
3751
3752    #[test]
3753    fn streaming_error_event_preserves_full_payload() {
3754        let payload = r#"{"type":"error","error":{"message":"boom","code":"server_error","type":"server_error"}}"#;
3755        let body = format!("data: {payload}\n");
3756
3757        let err = super::raw_choices_from_sse_body(&body, super::ResponsesUsage::new())
3758            .expect_err("error event should surface as a provider response error");
3759
3760        assert_eq!(err.provider_response_status(), None);
3761        assert_eq!(err.provider_response_body(), Some(payload));
3762        let json = err
3763            .provider_response_json()
3764            .expect("raw body should be valid JSON")
3765            .expect("parsed JSON should be present");
3766        assert_eq!(json["error"]["code"], "server_error");
3767    }
3768
3769    #[tokio::test]
3770    async fn streaming_non_http_transport_error_stays_provider_error() {
3771        use crate::http_client::sse::GenericEventSource;
3772        use crate::test_utils::SequencedStreamingHttpClient;
3773
3774        let chunks = vec![Err(crate::http_client::Error::InvalidContentType(
3775            http::HeaderValue::from_static("application/json"),
3776        ))];
3777        let client = SequencedStreamingHttpClient::new(chunks);
3778        let req = http::Request::builder()
3779            .method("POST")
3780            .uri("http://localhost/v1/responses")
3781            .body(Vec::new())
3782            .expect("request should build");
3783        let event_source = GenericEventSource::new(client, req);
3784        let span = tracing::Span::none();
3785        let mut stream = super::normalize_responses_stream(
3786            "openai",
3787            super::raw_stream_from_event_source(event_source, span),
3788        );
3789
3790        let err = stream
3791            .next()
3792            .await
3793            .expect("stream should yield transport error")
3794            .expect_err("non-HTTP transport failure should surface as provider error");
3795        assert_eq!(
3796            err.to_string(),
3797            "ProviderError: Invalid content type was returned: \"application/json\""
3798        );
3799        assert!(matches!(
3800            err,
3801            crate::completion::CompletionError::ProviderError(_)
3802        ));
3803        // Rig-generated transport diagnostics are not provider response bodies.
3804        assert_eq!(err.provider_response_body(), None);
3805        assert_eq!(err.provider_response_status(), None);
3806    }
3807
3808    #[tokio::test]
3809    async fn response_completed_chunk_populates_final_usage() {
3810        let mut response = sample_response(ResponseStatus::Completed);
3811        response.usage = Some(ResponsesUsage {
3812            input_tokens: 10,
3813            input_tokens_details: None,
3814            output_tokens: 5,
3815            output_tokens_details: Some(OutputTokensDetails {
3816                reasoning_tokens: 0,
3817            }),
3818            total_tokens: 15,
3819        });
3820
3821        let event = json!({
3822            "type": "response.completed",
3823            "sequence_number": 1,
3824            "response": response,
3825        });
3826
3827        let usage = final_response_from_event(event).await.usage;
3828        assert_eq!(usage.input_tokens, 10);
3829        assert_eq!(usage.output_tokens, 5);
3830        assert_eq!(usage.total_tokens, 15);
3831    }
3832
3833    #[tokio::test]
3834    async fn response_completed_chunk_populates_reasoning_metadata_and_context() {
3835        let response = sample_response(ResponseStatus::Completed);
3836        let mut event = json!({
3837            "type": "response.completed",
3838            "sequence_number": 1,
3839            "response": response,
3840        });
3841        let metadata = json!({
3842            "context": "all_turns",
3843            "effort": "ultra",
3844            "summary": null,
3845            "future_control": true
3846        });
3847        event["response"]["reasoning"] = metadata.clone();
3848
3849        let response = final_response_from_event(event).await;
3850        assert_eq!(response.reasoning_context.as_deref(), Some("all_turns"));
3851        assert_eq!(response.reasoning_metadata.as_ref(), metadata.as_object());
3852    }
3853
3854    #[tokio::test]
3855    async fn terminal_record_normalizes_into_the_stream_final() {
3856        let mut response = sample_response(ResponseStatus::Completed);
3857        response.usage = Some(ResponsesUsage {
3858            input_tokens: 10,
3859            input_tokens_details: None,
3860            output_tokens: 5,
3861            output_tokens_details: None,
3862            total_tokens: 15,
3863        });
3864
3865        let mut event = json!({
3866            "type": "response.completed",
3867            "sequence_number": 1,
3868            "response": response,
3869        });
3870        event["response"]["output"] = json!([{
3871            "type": "message",
3872            "id": "msg_stream_1",
3873            "status": "completed",
3874            "role": "assistant",
3875            "content": [{ "type": "output_text", "annotations": [], "text": "hi" }]
3876        }]);
3877
3878        let final_response = stream_final_from_event(event).await;
3879
3880        assert_eq!(final_response.provider, "openai");
3881        assert_eq!(final_response.model.as_deref(), Some("gpt-5.4"));
3882        // The assistant message ID (`msg_...`), never the response ID
3883        // (`resp_123`) that the same event carries.
3884        assert_eq!(final_response.message_id.as_deref(), Some("msg_stream_1"));
3885        assert_eq!(
3886            final_response.finish_reason,
3887            Some(crate::completion::FinishReason::Stop)
3888        );
3889        assert_eq!(final_response.usage.input_tokens, 10);
3890        assert_eq!(final_response.usage.output_tokens, 5);
3891        assert_eq!(final_response.usage.total_tokens, 15);
3892    }
3893
3894    #[tokio::test]
3895    async fn terminal_record_reports_tool_calls_when_the_stream_called_a_tool() {
3896        let tool_call_done = json!({
3897            "type": "response.output_item.done",
3898            "output_index": 0,
3899            "sequence_number": 1,
3900            "item": {
3901                "type": "function_call",
3902                "id": "fc_123",
3903                "arguments": "{}",
3904                "call_id": "call_123",
3905                "name": "example_tool",
3906                "status": "completed"
3907            }
3908        });
3909        let completed = json!({
3910            "type": "response.completed",
3911            "sequence_number": 2,
3912            "response": sample_response(ResponseStatus::Completed),
3913        });
3914
3915        let client = openai::Client::builder()
3916            .http_client(MockStreamingClient {
3917                sse_bytes: sse_bytes_from_json_events(&[tool_call_done, completed]),
3918            })
3919            .api_key("test-key")
3920            .build()
3921            .expect("client should build");
3922        let model = client.completion_model("gpt-5.4");
3923        let request = model.completion_request("hello").build();
3924        let mut stream = model.stream(request).await.expect("stream should start");
3925
3926        let mut final_response = None;
3927        while let Some(item) = stream.next().await {
3928            if let StreamedAssistantContent::Final(response) =
3929                item.expect("completed stream should not error")
3930            {
3931                final_response = Some(response);
3932            }
3933        }
3934
3935        // `completed` is reconciled up to `ToolCalls` by `normalize_stream`,
3936        // using the call the stream actually emitted.
3937        assert_eq!(
3938            final_response
3939                .expect("stream should yield a final response")
3940                .finish_reason,
3941            Some(crate::completion::FinishReason::ToolCalls)
3942        );
3943    }
3944
3945    #[test]
3946    fn terminal_record_preserves_an_unknown_incomplete_reason() {
3947        let response = super::StreamingCompletionResponse {
3948            status: Some(ResponseStatus::Incomplete),
3949            incomplete_details: Some(IncompleteDetailsReason {
3950                reason: "MAX_TOOL_CALLS".to_string(),
3951            }),
3952            model: Some("gpt-5.4".to_string()),
3953            message_id: Some("msg_1".to_string()),
3954            ..super::StreamingCompletionResponse::new(ResponsesUsage::new())
3955        };
3956
3957        let final_response = crate::streaming::StreamFinal::from(("openai", response));
3958
3959        assert_eq!(
3960            final_response.finish_reason,
3961            Some(crate::completion::FinishReason::Other(
3962                "MAX_TOOL_CALLS".to_string()
3963            ))
3964        );
3965        assert_eq!(final_response.message_id.as_deref(), Some("msg_1"));
3966        assert_eq!(final_response.model.as_deref(), Some("gpt-5.4"));
3967    }
3968
3969    #[tokio::test]
3970    async fn done_sentinel_is_ignored_without_debug_parse_noise() {
3971        use std::io::{self, Write};
3972        use std::sync::{Arc, Mutex};
3973
3974        #[derive(Clone)]
3975        struct SharedWriter(Arc<Mutex<Vec<u8>>>);
3976
3977        impl Write for SharedWriter {
3978            fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
3979                self.0
3980                    .lock()
3981                    .expect("log buffer mutex should not be poisoned")
3982                    .extend_from_slice(buf);
3983                Ok(buf.len())
3984            }
3985
3986            fn flush(&mut self) -> io::Result<()> {
3987                Ok(())
3988            }
3989        }
3990
3991        let mut response = sample_response(ResponseStatus::Completed);
3992        response.usage = Some(ResponsesUsage {
3993            input_tokens: 4,
3994            input_tokens_details: None,
3995            output_tokens: 2,
3996            output_tokens_details: Some(OutputTokensDetails {
3997                reasoning_tokens: 0,
3998            }),
3999            total_tokens: 6,
4000        });
4001
4002        // Scoped-subscriber tests must not run concurrently; see
4003        // `test_utils::scoped_tracing_subscriber_guard`.
4004        let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
4005        let captured = Arc::new(Mutex::new(Vec::new()));
4006        let subscriber = tracing_subscriber::fmt()
4007            .with_max_level(tracing::Level::DEBUG)
4008            .with_ansi(false)
4009            .without_time()
4010            .with_writer({
4011                let captured = captured.clone();
4012                move || SharedWriter(captured.clone())
4013            })
4014            .finish();
4015        let _guard = tracing::subscriber::set_default(subscriber);
4016
4017        let client = openai::Client::builder()
4018            .http_client(MockStreamingClient {
4019                sse_bytes: bytes::Bytes::from(format!(
4020                    "data: {}\n\ndata: [DONE]\n\n",
4021                    serde_json::to_string(&json!({
4022                        "type": "response.completed",
4023                        "sequence_number": 1,
4024                        "response": response,
4025                    }))
4026                    .expect("response event should serialize")
4027                )),
4028            })
4029            .api_key("test-key")
4030            .build()
4031            .expect("client should build");
4032        let model = client.completion_model("gpt-5.4");
4033        let request = model.completion_request("hello").build();
4034        let mut stream = model.stream(request).await.expect("stream should start");
4035
4036        let mut final_usage = None;
4037        while let Some(item) = stream.next().await {
4038            if let StreamedAssistantContent::Final(response) =
4039                item.expect("stream should complete successfully")
4040            {
4041                final_usage = Some(response.usage);
4042            }
4043        }
4044
4045        let usage = final_usage.expect("expected final response");
4046        assert_eq!(usage.input_tokens, 4);
4047        assert_eq!(usage.output_tokens, 2);
4048        assert_eq!(usage.total_tokens, 6);
4049
4050        let logs = String::from_utf8(
4051            captured
4052                .lock()
4053                .expect("log buffer mutex should not be poisoned")
4054                .clone(),
4055        )
4056        .expect("captured logs should be valid UTF-8");
4057        assert!(
4058            !logs.contains("Couldn't deserialize SSE data as StreamingCompletionChunk"),
4059            "expected [DONE] to bypass the parse-failure debug path, logs were: {logs}"
4060        );
4061    }
4062
4063    #[tokio::test]
4064    async fn malformed_frame_surfaces_error_and_stream_still_completes() {
4065        let delta = json!({
4066            "type": "response.output_text.delta",
4067            "content_index": 0,
4068            "delta": "hello",
4069            "item_id": "msg_1",
4070            "logprobs": [],
4071            "output_index": 0,
4072            "sequence_number": 1
4073        });
4074        let completed = json!({
4075            "type": "response.completed",
4076            "sequence_number": 2,
4077            "response": sample_response(ResponseStatus::Completed),
4078        });
4079        let http_client = MockStreamingClient {
4080            sse_bytes: sse_bytes_from_data_lines([
4081                delta.to_string(),
4082                "{not valid json".to_string(),
4083                completed.to_string(),
4084            ]),
4085        };
4086        let client = openai::Client::builder()
4087            .http_client(http_client)
4088            .api_key("test-key")
4089            .build()
4090            .expect("client should build");
4091        let model = client.completion_model("gpt-5.4");
4092        let request = model.completion_request("hello").build();
4093        let mut stream = model.stream(request).await.expect("stream should start");
4094
4095        let mut text = String::new();
4096        let mut saw_error = false;
4097        let mut terminal = None;
4098        while let Some(item) = stream.next().await {
4099            match item {
4100                Ok(StreamedAssistantContent::Text(chunk)) => text.push_str(&chunk.text),
4101                Ok(StreamedAssistantContent::Final(final_response)) => {
4102                    terminal = Some(final_response)
4103                }
4104                Ok(other) => panic!("unexpected stream item: {other:?}"),
4105                Err(err) => {
4106                    assert!(
4107                        matches!(err, crate::completion::CompletionError::JsonError(_)),
4108                        "expected a JSON parse error item, got {err:?}"
4109                    );
4110                    saw_error = true;
4111                }
4112            }
4113        }
4114
4115        // The malformed frame is surfaced as an error item, and the content
4116        // and genuine terminal on either side of it both still arrive.
4117        assert_eq!(text, "hello");
4118        assert!(saw_error, "malformed frame should surface an error item");
4119        assert!(
4120            terminal.is_some(),
4121            "stream should still emit its terminal record"
4122        );
4123    }
4124}