1use 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#[derive(Debug, Serialize, Deserialize, Clone)]
36#[serde(untagged)]
37pub enum StreamingCompletionChunk {
38 Response(Box<ResponseChunk>),
39 Delta(ItemChunk),
40}
41
42#[derive(Debug, Serialize, Deserialize, Clone)]
49pub struct StreamingCompletionResponse {
50 pub usage: ResponsesUsage,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub reasoning_metadata: Option<serde_json::Map<String, serde_json::Value>>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub reasoning_context: Option<String>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub status: Option<ResponseStatus>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub incomplete_details: Option<IncompleteDetailsReason>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub message_id: Option<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub response_id: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub model: Option<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub provider_request_id: Option<String>,
83}
84
85impl StreamingCompletionResponse {
86 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
103impl 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
127pub(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
147pub(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 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#[derive(Debug, Serialize, Deserialize, Clone)]
191pub struct ResponseChunk {
192 #[serde(rename = "type")]
194 pub kind: ResponseChunkKind,
195 pub response: CompletionResponse,
197 pub sequence_number: u64,
199}
200
201#[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
232fn 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
265pub(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
303fn 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 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 tool_calls: Vec<StreamingRawChoice>,
389 saw_terminal: bool,
393 reasoning_slots: std::collections::HashMap<u64, crate::streaming::StreamPartId>,
404 tool_slots: crate::providers::internal::tool_call_bridge::ToolCallBridge<u64>,
416 pending_call_ids: std::collections::HashMap<u64, String>,
421 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 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 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 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 self.current_text_item = None;
521 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 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 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 ItemChunkKind::ReasoningSummaryTextDelta(SummaryTextChunk { delta, .. })
564 | ItemChunkKind::ReasoningTextDelta(DeltaTextChunkWithItemId { delta, .. }) => {
565 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 self.current_text_item = None;
583 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 ResponseChunkKind::ResponseCompleted | ResponseChunkKind::ResponseIncomplete => {
616 self.saw_terminal = true;
617 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 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 let slot = self.tool_slots.remove(output_index);
685 self.pending_call_ids.remove(&output_index);
688 let item_id = match &slot {
689 Some(slot) => slot.key().clone(),
693 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 end.tool_id = crate::streaming::WireId::new(func.id.clone());
710 match func.arguments.parse() {
719 Ok(arguments) => end.arguments = Some(arguments),
720 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 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 Output::Unknown(value) => {
785 immediate.push(streaming::RawStreamingChoice::Unknown(value.into()));
786 }
787 }
788 }
789
790 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 if !self.saw_terminal {
806 return choices;
807 }
808 choices.push(RawStreamingChoice::FinalResponse(
809 StreamingCompletionResponse {
810 usage: self.final_usage,
811 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
826fn 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 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 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
933pub(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 let mut choice = std::mem::take(&mut stream.choice);
967 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
1048pub(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 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 return FrameDisposition::Fail(error);
1091 }
1092 FrameDisposition::Frame(data)
1093 },
1094 ResponsesAdapter::live(options),
1095 span,
1096 )
1097}
1098
1099pub(crate) struct ResponsesFrameEvent {
1103 raw: String,
1104 chunk: StreamingCompletionChunk,
1105}
1106
1107pub(crate) struct ResponsesAdapter {
1117 accumulator: RawChoiceAccumulator,
1118 options: ResponsesStreamOptions,
1119 repair_envelopes: bool,
1121 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 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 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 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 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#[derive(Debug, Serialize, Deserialize, Clone)]
1261pub struct ItemChunk {
1262 pub item_id: Option<String>,
1264 pub output_index: u64,
1266 #[serde(flatten)]
1268 pub data: ItemChunkKind,
1269}
1270
1271#[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 #[serde(rename = "response.reasoning_text.done")]
1320 ReasoningTextDone(OutputTextChunk),
1321 }
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 #[serde(untagged)]
1358 Unknown(serde_json::Value),
1359}
1360
1361impl<'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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 #[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 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 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 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 #[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 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 #[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 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 #[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 #[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 #[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 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 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 #[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 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 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 #[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 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 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 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 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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 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 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 #[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 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 #[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 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 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 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 #[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 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 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 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 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 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}