use crate::completion::{self, CompletionError};
use crate::http_client::HttpClientExt;
use crate::http_client::sse::GenericEventSource;
use crate::providers::internal::adapter::{
AdapterOutput, WireAdapter, WireFrame, run_wire_buffered,
};
use crate::providers::internal::sse_transport::{
FrameDisposition, OpenLog, SseTransportOptions, open_wire_stream,
};
use crate::providers::internal::wire::{self, WireEvent};
use crate::providers::openai::responses_api::{
IncompleteDetailsReason, ReasoningSummary, ResponseStatus, ResponsesUsage,
};
use crate::streaming;
use crate::streaming::RawStreamingChoice;
use crate::telemetry::{CompletionOperation, CompletionSpanBuilder};
use crate::wasm_compat::WasmCompatSend;
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use super::{CompletionResponse, GenericResponsesCompletionModel, Output, ResponsesProviderExt};
type StreamingRawChoice = RawStreamingChoice<StreamingCompletionResponse>;
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum StreamingCompletionChunk {
Response(Box<ResponseChunk>),
Delta(ItemChunk),
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct StreamingCompletionResponse {
pub usage: ResponsesUsage,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_metadata: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_context: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<ResponseStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub incomplete_details: Option<IncompleteDetailsReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_request_id: Option<String>,
}
impl StreamingCompletionResponse {
pub fn new(usage: ResponsesUsage) -> Self {
Self {
usage,
provider_request_id: None,
reasoning_metadata: None,
reasoning_context: None,
status: None,
incomplete_details: None,
message_id: None,
response_id: None,
model: None,
}
}
}
impl From<(&str, StreamingCompletionResponse)> for streaming::StreamFinal {
fn from((provider, response): (&str, StreamingCompletionResponse)) -> Self {
let finish_reason = response.status.as_ref().and_then(|status| {
super::map_finish_reason(status, response.incomplete_details.as_ref())
});
streaming::StreamFinal::new(provider, crate::completion::Usage::from(&response.usage))
.with_optional_finish_reason(finish_reason)
.with_optional_message_id(response.message_id)
.with_optional_response_id(response.response_id)
.with_optional_provider_request_id(response.provider_request_id)
.with_optional_model(response.model)
}
}
pub(crate) fn normalize_responses_stream(
provider: &str,
raw: streaming::RawStreamingResult<StreamingCompletionResponse>,
) -> streaming::StreamingCompletionResponse {
let provider = provider.to_owned();
let mapped_provider = provider.clone();
let normalized = streaming::normalize_stream(raw, move |response| {
Ok(streaming::StreamFinal::from((
mapped_provider.as_str(),
response,
)))
});
streaming::StreamingCompletionResponse::stream(provider, normalized)
}
pub(crate) fn reasoning_end_from_done_item(
id: &crate::streaming::StreamPartId,
provider_id: Option<&crate::streaming::WireId>,
summary: Vec<ReasoningSummary>,
content: Vec<String>,
encrypted_content: Option<String>,
) -> Option<RawStreamingChoice<StreamingCompletionResponse>> {
let blocks = super::reasoning_content_blocks(summary, content, encrypted_content);
if blocks.is_empty() {
return None;
}
Some(RawStreamingChoice::ReasoningEnd {
id: id.clone(),
reasoning: Some(crate::message::Reasoning {
id: provider_id.map(|provider_id| provider_id.as_str().to_owned()),
content: blocks,
}),
signature: None,
wire_sent: true,
})
}
impl From<&StreamingCompletionResponse> for crate::completion::Usage {
fn from(response: &StreamingCompletionResponse) -> Self {
Self::from(&response.usage)
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ResponseChunk {
#[serde(rename = "type")]
pub kind: ResponseChunkKind,
pub response: CompletionResponse,
pub sequence_number: u64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub enum ResponseChunkKind {
#[serde(rename = "response.created")]
ResponseCreated,
#[serde(rename = "response.in_progress")]
ResponseInProgress,
#[serde(rename = "response.completed")]
ResponseCompleted,
#[serde(rename = "response.failed")]
ResponseFailed,
#[serde(rename = "response.incomplete")]
ResponseIncomplete,
}
fn provider_response_from_responses_error_value(
value: &serde_json::Value,
data: &str,
) -> CompletionError {
if let Some(message) = value
.get("error")
.and_then(|error| error.get("message"))
.and_then(serde_json::Value::as_str)
{
tracing::warn!(message, "provider returned a streaming error event");
}
crate::provider_response::completion_error_from_body(data)
}
fn is_known_responses_event_type(kind: &str) -> bool {
matches!(
kind,
"response.created"
| "response.in_progress"
| "response.completed"
| "response.failed"
| "response.incomplete"
| "response.output_item.added"
| "response.output_item.done"
| "response.content_part.added"
| "response.content_part.done"
| "response.output_text.delta"
| "response.output_text.done"
| "response.refusal.delta"
| "response.refusal.done"
| "response.function_call_arguments.delta"
| "response.function_call_arguments.done"
| "response.reasoning_summary_part.added"
| "response.reasoning_summary_part.done"
| "response.reasoning_summary_text.delta"
| "response.reasoning_summary_text.done"
| "response.reasoning_text.delta"
| "response.reasoning_text.done"
)
}
pub(super) fn classify_responses_frame(data: &str) -> WireEvent<StreamingCompletionChunk> {
wire::classify_tagged_frame(data, "type", is_known_responses_event_type)
}
fn provider_response_from_responses_sse_data(data: &str) -> Option<CompletionError> {
let value = serde_json::from_str::<serde_json::Value>(data).ok()?;
(value.get("type").and_then(serde_json::Value::as_str) == Some("error"))
.then(|| provider_response_from_responses_error_value(&value, data))
}
#[derive(Clone, Copy)]
pub(crate) enum ResponsesStreamOptions {
Strict,
StrictWithImmediateToolCalls,
}
impl ResponsesStreamOptions {
pub(crate) const fn strict() -> Self {
Self::Strict
}
pub(crate) const fn strict_with_immediate_tool_calls() -> Self {
Self::StrictWithImmediateToolCalls
}
const fn emits_completed_tool_calls_immediately(self) -> bool {
matches!(self, Self::StrictWithImmediateToolCalls)
}
}
fn sse_data_frames(body: &str) -> impl Iterator<Item = &str> {
body.lines()
.map(|line| {
line.strip_prefix("data:")
.map(str::trim)
.unwrap_or_default()
})
.filter(|data| !data.is_empty() && *data != "[DONE]")
}
pub(crate) fn parse_sse_completion_body(
body: &str,
provider_name: &str,
) -> Result<CompletionResponse, CompletionError> {
let mut completed = None;
for data in sse_data_frames(body) {
if let Ok(chunk) = serde_json::from_str::<StreamingCompletionChunk>(data) {
if let StreamingCompletionChunk::Response(chunk) = chunk {
let ResponseChunk { kind, response, .. } = *chunk;
match kind {
ResponseChunkKind::ResponseCompleted
| ResponseChunkKind::ResponseIncomplete => {
completed = Some(response);
break;
}
ResponseChunkKind::ResponseFailed => {
return Err(crate::provider_response::completion_error_from_body(data));
}
_ => {}
}
}
continue;
}
let value = match serde_json::from_str::<serde_json::Value>(data) {
Ok(value) => value,
Err(_) => continue,
};
match value.get("type").and_then(serde_json::Value::as_str) {
Some("response.completed") | Some("response.incomplete") => {
if let Some(response) = value.get("response") {
completed = Some(serde_json::from_value(response.clone())?);
break;
}
}
Some("response.failed") => {
return Err(crate::provider_response::completion_error_from_body(data));
}
Some("error") => {
return Err(provider_response_from_responses_error_value(&value, data));
}
_ => {}
}
}
completed.ok_or_else(|| {
CompletionError::ProviderError(format!(
"{provider_name} stream did not yield a terminal response event (response.completed or response.incomplete)"
))
})
}
pub(crate) struct RawChoiceAccumulator {
final_usage: ResponsesUsage,
reasoning_metadata: Option<serde_json::Map<String, serde_json::Value>>,
reasoning_context: Option<String>,
status: Option<ResponseStatus>,
incomplete_details: Option<IncompleteDetailsReason>,
message_id: Option<String>,
response_id: Option<String>,
model: Option<String>,
tool_calls: Vec<StreamingRawChoice>,
saw_terminal: bool,
reasoning_slots: std::collections::HashMap<u64, crate::streaming::StreamPartId>,
tool_slots: crate::providers::internal::tool_call_bridge::ToolCallBridge<u64>,
pending_call_ids: std::collections::HashMap<u64, String>,
current_text_item: Option<String>,
}
impl RawChoiceAccumulator {
pub(crate) fn new(initial_usage: ResponsesUsage) -> Self {
Self {
final_usage: initial_usage,
reasoning_metadata: None,
reasoning_context: None,
status: None,
incomplete_details: None,
message_id: None,
response_id: None,
model: None,
tool_calls: Vec::new(),
saw_terminal: false,
reasoning_slots: std::collections::HashMap::new(),
tool_slots:
crate::providers::internal::tool_call_bridge::ToolCallBridge::with_minted_namespace(
crate::streaming::SyntheticIds::output(),
),
pending_call_ids: std::collections::HashMap::new(),
current_text_item: None,
}
}
fn start_text_item(
&mut self,
item_id: &Option<String>,
immediate: &mut Vec<StreamingRawChoice>,
) {
if let Some(item_id) = item_id
&& self.current_text_item.as_deref() != Some(item_id)
{
self.current_text_item = Some(item_id.clone());
immediate.push(streaming::RawStreamingChoice::TextStart {
id: crate::streaming::StreamPartId::wire(item_id.clone()),
additional_params: None,
});
}
}
fn reasoning_slot_key(
&mut self,
output_index: u64,
item_id: Option<&str>,
) -> crate::streaming::StreamPartId {
if let Some(key) = self.reasoning_slots.get(&output_index) {
return key.clone();
}
let key = item_id
.map(crate::streaming::StreamPartId::wire)
.unwrap_or_else(|| self.tool_slots.minted_ids().mint());
self.reasoning_slots.insert(output_index, key.clone());
key
}
pub(crate) fn decode_item_chunk(
&mut self,
chunk: ItemChunk,
options: ResponsesStreamOptions,
) -> Vec<StreamingRawChoice> {
let mut immediate = Vec::new();
let ItemChunk {
item_id: outer_item_id,
output_index,
data: item,
} = chunk;
match item {
ItemChunkKind::OutputItemAdded(StreamingItemDoneOutput {
item: Output::FunctionCall(func),
..
}) => {
self.current_text_item = None;
let key = self
.tool_slots
.open(output_index, Some(&func.id), Some(&func.name))
.key()
.to_owned();
if !func.call_id.is_empty() {
self.pending_call_ids
.insert(output_index, func.call_id.clone());
}
immediate.push(streaming::RawStreamingChoice::ToolCallDelta {
id: key,
content: streaming::ToolCallDeltaContent::Name(func.name),
});
}
ItemChunkKind::OutputItemDone(message) => {
self.current_text_item = None;
self.push_output_item_done(
message.item,
output_index,
&mut immediate,
options.emits_completed_tool_calls_immediately(),
);
}
ItemChunkKind::OutputTextDelta(DeltaTextChunk { delta, .. })
| ItemChunkKind::RefusalDelta(DeltaTextChunk { delta, .. }) => {
self.start_text_item(&outer_item_id, &mut immediate);
immediate.push(streaming::RawStreamingChoice::Message(delta));
}
ItemChunkKind::ReasoningSummaryTextDelta(SummaryTextChunk { delta, .. })
| ItemChunkKind::ReasoningTextDelta(DeltaTextChunkWithItemId { delta, .. }) => {
self.current_text_item = None;
let id = self.reasoning_slot_key(output_index, outer_item_id.as_deref());
immediate.push(streaming::RawStreamingChoice::ReasoningDelta {
id,
provider_id: outer_item_id
.clone()
.and_then(crate::streaming::WireId::new),
reasoning: delta,
});
}
ItemChunkKind::FunctionCallArgsDelta(delta) => {
self.current_text_item = None;
let slot = self
.tool_slots
.open(output_index, outer_item_id.as_deref(), None);
slot.saw_arguments_delta = true;
let key = slot.key().clone();
immediate.push(streaming::RawStreamingChoice::ToolCallDelta {
id: key,
content: streaming::ToolCallDeltaContent::Delta(delta.delta),
});
}
_ => {}
}
immediate
}
pub(crate) fn record_response_chunk(
&mut self,
kind: ResponseChunkKind,
response: CompletionResponse,
raw_event_data: &str,
) -> Result<(), CompletionError> {
match kind {
ResponseChunkKind::ResponseCompleted | ResponseChunkKind::ResponseIncomplete => {
self.saw_terminal = true;
for (index, slot) in self.tool_slots.drain_ordered_indexed() {
let mut end = slot.end_event(streaming::UnparseableToolInput::Drop);
end.call_id = self.pending_call_ids.remove(&index);
self.tool_calls
.push(streaming::RawStreamingChoice::ToolInputEnd(end));
}
if let Some(message_id) = message_id_from_response(&response) {
self.message_id = Some(message_id);
}
if !response.id.is_empty() {
self.response_id = Some(response.id.clone());
}
if !response.model.is_empty() {
self.model = Some(response.model.clone());
}
self.status = Some(response.status);
if response.incomplete_details.is_some() {
self.incomplete_details = response.incomplete_details;
}
if let Some(usage) = response.usage {
self.final_usage = usage;
}
if response.reasoning_metadata.is_some() {
self.reasoning_metadata = response.reasoning_metadata;
}
if response.reasoning_context.is_some() {
self.reasoning_context = response.reasoning_context;
}
Ok(())
}
ResponseChunkKind::ResponseFailed => Err(
crate::provider_response::completion_error_from_body(raw_event_data),
),
_ => Ok(()),
}
}
fn push_output_item_done(
&mut self,
item: Output,
output_index: u64,
immediate: &mut Vec<StreamingRawChoice>,
emit_completed_tool_calls_immediately: bool,
) {
match item {
Output::FunctionCall(func) => {
let slot = self.tool_slots.remove(output_index);
self.pending_call_ids.remove(&output_index);
let item_id = match &slot {
Some(slot) => slot.key().clone(),
None if func.id.is_empty() => self.tool_slots.minted_ids().mint(),
None => crate::streaming::StreamPartId::wire(func.id.clone()),
};
let mut end = streaming::ToolInputEnd::new(
item_id.clone(),
streaming::UnparseableToolInput::Drop,
);
end.name = Some(func.name);
end.tool_id = crate::streaming::WireId::new(func.id.clone());
match func.arguments.parse() {
Ok(arguments) => end.arguments = Some(arguments),
Err(_) => {
let saw_fragments =
slot.as_ref().is_some_and(|slot| slot.saw_arguments_delta);
if !saw_fragments {
immediate.push(streaming::RawStreamingChoice::ToolCallDelta {
id: item_id,
content: streaming::ToolCallDeltaContent::Delta(
func.arguments.as_str().to_owned(),
),
});
}
}
}
end.call_id = Some(func.call_id);
let end = streaming::RawStreamingChoice::ToolInputEnd(end);
if emit_completed_tool_calls_immediately {
immediate.push(end);
} else {
self.tool_calls.push(end);
}
}
Output::Reasoning {
id,
summary,
content,
encrypted_content,
..
} => {
let provider_id = crate::streaming::WireId::new(id.clone());
let key = self
.reasoning_slots
.remove(&output_index)
.unwrap_or(crate::streaming::StreamPartId::wire(id));
immediate.extend(reasoning_end_from_done_item(
&key,
provider_id.as_ref(),
summary,
content,
encrypted_content,
));
}
Output::Message(message) => {
immediate.push(streaming::RawStreamingChoice::MessageId(message.id));
}
Output::Unknown(value) => {
immediate.push(streaming::RawStreamingChoice::Unknown(value.into()));
}
}
}
pub(crate) fn take_tool_calls(&mut self) -> Vec<StreamingRawChoice> {
std::mem::take(&mut self.tool_calls)
}
pub(crate) fn finish(mut self) -> Vec<StreamingRawChoice> {
let mut choices = Vec::new();
choices.append(&mut self.tool_calls);
if !self.saw_terminal {
return choices;
}
choices.push(RawStreamingChoice::FinalResponse(
StreamingCompletionResponse {
usage: self.final_usage,
provider_request_id: None,
reasoning_metadata: self.reasoning_metadata,
reasoning_context: self.reasoning_context,
status: self.status,
incomplete_details: self.incomplete_details,
message_id: self.message_id,
response_id: self.response_id,
model: self.model,
},
));
choices
}
}
fn repair_envelope_less_frame(data: &str) -> Option<String> {
let mut value = serde_json::from_str::<serde_json::Value>(data).ok()?;
let object = value.as_object_mut()?;
for field in [
"sequence_number",
"output_index",
"content_index",
"summary_index",
] {
object
.entry(field)
.or_insert_with(|| serde_json::Value::from(0));
}
serde_json::to_string(&value).ok()
}
pub(crate) fn raw_choices_from_sse_body(
body: &str,
initial_usage: ResponsesUsage,
) -> Result<Vec<StreamingRawChoice>, CompletionError> {
let mut frames = Vec::new();
for data in sse_data_frames(body) {
if let Some(error) = provider_response_from_responses_sse_data(data) {
return Err(error);
}
frames.push(WireFrame::Text(data.to_owned()));
}
run_wire_buffered(frames, ResponsesAdapter::buffered(initial_usage))
}
pub(crate) async fn completion_response_from_sse_body(
provider: &str,
body: &str,
raw_response: CompletionResponse,
) -> Result<completion::CompletionResponse, CompletionError> {
let raw_choices = raw_choices_from_sse_body(
body,
raw_response
.usage
.clone()
.unwrap_or_else(ResponsesUsage::new),
)?;
completion_response_from_raw_choices(provider, raw_choices, &raw_response)
.await?
.ok_or_else(|| CompletionError::ResponseError("Response contained no parts".to_owned()))
}
pub(crate) async fn completion_response_from_raw_choices(
provider: &str,
raw_choices: Vec<StreamingRawChoice>,
raw_response: &CompletionResponse,
) -> Result<Option<completion::CompletionResponse>, CompletionError> {
let stream = futures::stream::iter(
raw_choices
.into_iter()
.map(Ok::<_, CompletionError>)
.collect::<Vec<_>>(),
);
let mut stream = normalize_responses_stream(provider, Box::pin(stream));
while let Some(item) = stream.next().await {
item?;
}
if choice_is_empty(&stream.choice) {
return Ok(None);
}
let mut choice = std::mem::take(&mut stream.choice);
let replay_has_message_text = choice.iter().any(|content| {
matches!(
content,
completion::AssistantContent::Text(text) if !text.text.is_empty()
)
});
if !replay_has_message_text {
choice.extend(
raw_response
.output
.iter()
.filter(|item| matches!(item, Output::Message(_)))
.cloned()
.flat_map(<Vec<completion::AssistantContent>>::from),
);
}
let terminal = stream.response.clone();
let usage = terminal
.as_ref()
.map(|terminal| terminal.usage)
.unwrap_or_else(|| usage_from_raw_response(raw_response));
let message_id = stream
.message_id
.clone()
.or_else(|| message_id_from_response(raw_response));
let finish_reason = terminal
.as_ref()
.and_then(|terminal| terminal.finish_reason.clone())
.or_else(|| {
super::map_finish_reason(
&raw_response.status,
raw_response.incomplete_details.as_ref(),
)
});
let model = terminal
.as_ref()
.and_then(|terminal| terminal.model.clone())
.or_else(|| Some(raw_response.model.clone()).filter(|model| !model.is_empty()));
let response_id = stream
.response
.as_ref()
.and_then(|terminal| terminal.response_id.clone())
.or_else(|| Some(raw_response.id.clone()).filter(|id| !id.is_empty()));
Ok(Some(
completion::CompletionResponse::new(choice, usage, provider)
.with_optional_message_id(message_id)
.with_optional_response_id(response_id)
.with_optional_model(model)
.with_optional_finish_reason(finish_reason),
))
}
fn choice_is_empty(choice: &[completion::AssistantContent]) -> bool {
choice.iter().all(|content| match content {
completion::AssistantContent::Text(text) => text.text.trim().is_empty(),
completion::AssistantContent::Reasoning(reasoning) => reasoning.content.is_empty(),
completion::AssistantContent::Image(_) => false,
completion::AssistantContent::ToolCall(_) => false,
})
}
fn message_id_from_response(response: &CompletionResponse) -> Option<String> {
response.output.iter().find_map(|item| match item {
Output::Message(message) => Some(message.id.clone()),
_ => None,
})
}
fn usage_from_raw_response(response: &CompletionResponse) -> completion::Usage {
response
.usage
.as_ref()
.map(completion::Usage::from)
.unwrap_or_default()
}
pub(crate) fn raw_stream_from_event_source<HttpClient, RequestBody>(
event_source: GenericEventSource<HttpClient, RequestBody>,
span: tracing::Span,
) -> streaming::RawStreamingResult<StreamingCompletionResponse>
where
HttpClient: HttpClientExt + Clone + 'static,
RequestBody: Into<bytes::Bytes> + Clone + WasmCompatSend + 'static,
{
raw_stream_from_event_source_with_options(event_source, span, ResponsesStreamOptions::strict())
}
pub(crate) fn raw_stream_from_event_source_with_options<HttpClient, RequestBody>(
event_source: GenericEventSource<HttpClient, RequestBody>,
span: tracing::Span,
options: ResponsesStreamOptions,
) -> streaming::RawStreamingResult<StreamingCompletionResponse>
where
HttpClient: HttpClientExt + Clone + 'static,
RequestBody: Into<bytes::Bytes> + Clone + WasmCompatSend + 'static,
{
open_wire_stream(
event_source,
SseTransportOptions {
open_log: OpenLog::Trace,
stream_ended_is_error: false,
log_transport_errors: true,
},
|data| {
if data.trim().is_empty() || data == "[DONE]" {
return FrameDisposition::Skip;
}
if let Some(error) = provider_response_from_responses_sse_data(&data) {
return FrameDisposition::Fail(error);
}
FrameDisposition::Frame(data)
},
ResponsesAdapter::live(options),
span,
)
}
pub(crate) struct ResponsesFrameEvent {
raw: String,
chunk: StreamingCompletionChunk,
}
pub(crate) struct ResponsesAdapter {
accumulator: RawChoiceAccumulator,
options: ResponsesStreamOptions,
repair_envelopes: bool,
finished: bool,
}
impl ResponsesAdapter {
fn live(options: ResponsesStreamOptions) -> Self {
Self {
accumulator: RawChoiceAccumulator::new(ResponsesUsage::new()),
options,
repair_envelopes: false,
finished: false,
}
}
fn buffered(initial_usage: ResponsesUsage) -> Self {
Self {
accumulator: RawChoiceAccumulator::new(initial_usage),
options: ResponsesStreamOptions::strict(),
repair_envelopes: true,
finished: false,
}
}
}
impl WireAdapter for ResponsesAdapter {
type Frame = WireFrame;
type Event = ResponsesFrameEvent;
type Response = StreamingCompletionResponse;
fn classify(&self, frame: WireFrame) -> WireEvent<ResponsesFrameEvent> {
let data = frame.as_str().into_owned();
let event = if self.repair_envelopes {
wire::classify_with_repair(
&data,
classify_responses_frame,
repair_envelope_less_frame,
|corrupt| {
<serde_json::Error as serde::de::Error>::custom(format!(
"invalid JSON frame in buffered Responses SSE body: {corrupt}"
))
},
|| {
let kind = serde_json::from_str::<serde_json::Value>(&data)
.ok()
.and_then(|value| {
value
.get("type")
.and_then(serde_json::Value::as_str)
.map(ToOwned::to_owned)
})
.unwrap_or_default();
<serde_json::Error as serde::de::Error>::custom(format!(
"malformed `{kind}` event in buffered Responses SSE body"
))
},
)
} else {
classify_responses_frame(&data)
};
event.map(|chunk| ResponsesFrameEvent { raw: data, chunk })
}
fn interpret(&mut self, event: ResponsesFrameEvent, out: &mut AdapterOutput<Self::Response>) {
if self.finished {
return;
}
match event.chunk {
StreamingCompletionChunk::Delta(chunk) => {
out.extend(
self.accumulator
.decode_item_chunk(chunk, self.options)
.into_iter()
.map(Ok),
);
}
StreamingCompletionChunk::Response(chunk) => {
let ResponseChunk { kind, response, .. } = *chunk;
if matches!(kind, ResponseChunkKind::ResponseCompleted) {
let span = tracing::Span::current();
span.record("gen_ai.response.id", response.id.as_str());
span.record("gen_ai.response.model", response.model.as_str());
}
if let Err(error) = self
.accumulator
.record_response_chunk(kind, response, &event.raw)
{
out.extend(self.accumulator.take_tool_calls().into_iter().map(Ok));
out.push(Err(error));
self.finished = true;
}
}
}
}
fn finish(&mut self, out: &mut AdapterOutput<Self::Response>) {
let accumulator = std::mem::replace(
&mut self.accumulator,
RawChoiceAccumulator::new(ResponsesUsage::new()),
);
let final_usage = accumulator.final_usage.clone();
out.extend(accumulator.finish().into_iter().map(Ok));
let span = tracing::Span::current();
span.record("gen_ai.usage.input_tokens", final_usage.input_tokens);
span.record("gen_ai.usage.output_tokens", final_usage.output_tokens);
let cached_tokens = final_usage
.input_tokens_details
.as_ref()
.map(|d| d.cached_tokens)
.unwrap_or(0);
span.record("gen_ai.usage.cache_read.input_tokens", cached_tokens);
}
fn flush_before_terminal_error(&mut self, out: &mut AdapterOutput<Self::Response>) {
out.extend(self.accumulator.take_tool_calls().into_iter().map(Ok));
}
fn is_finished(&self) -> bool {
self.finished
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ItemChunk {
pub item_id: Option<String>,
pub output_index: u64,
#[serde(flatten)]
pub data: ItemChunkKind,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "type")]
pub enum ItemChunkKind {
#[serde(rename = "response.output_item.added")]
OutputItemAdded(StreamingItemDoneOutput),
#[serde(rename = "response.output_item.done")]
OutputItemDone(StreamingItemDoneOutput),
#[serde(rename = "response.content_part.added")]
ContentPartAdded(ContentPartChunk),
#[serde(rename = "response.content_part.done")]
ContentPartDone(ContentPartChunk),
#[serde(rename = "response.output_text.delta")]
OutputTextDelta(DeltaTextChunk),
#[serde(rename = "response.output_text.done")]
OutputTextDone(OutputTextChunk),
#[serde(rename = "response.refusal.delta")]
RefusalDelta(DeltaTextChunk),
#[serde(rename = "response.refusal.done")]
RefusalDone(RefusalTextChunk),
#[serde(rename = "response.function_call_arguments.delta")]
FunctionCallArgsDelta(DeltaTextChunkWithItemId),
#[serde(rename = "response.function_call_arguments.done")]
FunctionCallArgsDone(ArgsTextChunk),
#[serde(rename = "response.reasoning_summary_part.added")]
ReasoningSummaryPartAdded(SummaryPartChunk),
#[serde(rename = "response.reasoning_summary_part.done")]
ReasoningSummaryPartDone(SummaryPartChunk),
#[serde(rename = "response.reasoning_summary_text.delta")]
ReasoningSummaryTextDelta(SummaryTextChunk),
#[serde(rename = "response.reasoning_summary_text.done")]
ReasoningSummaryTextDone(SummaryTextChunk),
#[serde(rename = "response.reasoning_text.delta")]
ReasoningTextDelta(DeltaTextChunkWithItemId),
#[serde(rename = "response.reasoning_text.done")]
ReasoningTextDone(OutputTextChunk),
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct StreamingItemDoneOutput {
pub sequence_number: u64,
pub item: Output,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ContentPartChunk {
pub content_index: u64,
pub sequence_number: u64,
pub part: ContentPartChunkPart,
}
#[derive(Debug, Serialize, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentPartChunkPart {
OutputText {
text: String,
},
SummaryText {
text: String,
},
#[serde(untagged)]
Unknown(serde_json::Value),
}
impl<'de> Deserialize<'de> for ContentPartChunkPart {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
let text_field = |part: &str| -> Result<String, D::Error> {
value
.get("text")
.and_then(serde_json::Value::as_str)
.map(ToOwned::to_owned)
.ok_or_else(|| {
serde::de::Error::custom(format!(
"`{part}` content part is missing a string `text` field"
))
})
};
match value.get("type").cloned() {
Some(serde_json::Value::String(tag)) => match tag.as_str() {
"output_text" => Ok(Self::OutputText {
text: text_field("output_text")?,
}),
"summary_text" => Ok(Self::SummaryText {
text: text_field("summary_text")?,
}),
_ => Ok(Self::Unknown(value)),
},
Some(_) => Err(serde::de::Error::custom(
"content part `type` must be a string",
)),
None => Ok(Self::Unknown(value)),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DeltaTextChunk {
pub content_index: u64,
pub sequence_number: u64,
pub delta: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DeltaTextChunkWithItemId {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_index: Option<u64>,
pub sequence_number: u64,
pub delta: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct OutputTextChunk {
pub content_index: u64,
pub sequence_number: u64,
pub text: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RefusalTextChunk {
pub content_index: u64,
pub sequence_number: u64,
pub refusal: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ArgsTextChunk {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_index: Option<u64>,
pub sequence_number: u64,
pub arguments: serde_json::Value,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SummaryPartChunk {
pub summary_index: u64,
pub sequence_number: u64,
pub part: SummaryPartChunkPart,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SummaryTextChunk {
pub summary_index: u64,
pub sequence_number: u64,
#[serde(alias = "text")]
pub delta: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SummaryPartChunkPart {
SummaryText { text: String },
}
impl<Ext, H> GenericResponsesCompletionModel<Ext, H>
where
crate::client::Client<Ext, H>: HttpClientExt + Clone + WasmCompatSend + 'static,
Ext: crate::client::Provider + ResponsesProviderExt + Clone + 'static,
H: Clone + WasmCompatSend + 'static,
{
pub async fn raw_stream(
&self,
completion_request: crate::completion::CompletionRequest,
) -> Result<streaming::RawStreamingResult<StreamingCompletionResponse>, CompletionError> {
let system_instructions = completion_request.preamble.clone();
let record_telemetry_content = completion_request.record_telemetry_content;
let (request_model, request) = self.create_provider_request(completion_request, true)?;
crate::providers::internal::trace_json(
crate::providers::internal::LogTarget::Completions,
"Responses streaming completion request",
&request,
);
let body = serde_json::to_vec(&request)?;
let req = self
.client
.post(Ext::RESPONSES_PATH)?
.body(body)
.map_err(|e| CompletionError::HttpError(e.into()))?;
let span = CompletionSpanBuilder::new(
Ext::PROVIDER_NAME,
&request_model,
CompletionOperation::ChatStreaming,
)
.system_instructions(system_instructions.as_deref(), record_telemetry_content)
.build();
let client = self.client.clone();
let event_source = GenericEventSource::new(client, req);
let (event_source, request_id_slot) = match Ext::REQUEST_ID_HEADER {
Some(header) => {
let (event_source, slot) = event_source.capture_request_id(header);
(event_source, Some(slot))
}
None => (event_source, None),
};
let options = if Ext::EMITS_COMPLETE_TOOL_CALLS_IMMEDIATELY {
ResponsesStreamOptions::strict_with_immediate_tool_calls()
} else {
ResponsesStreamOptions::strict()
};
let stream = raw_stream_from_event_source_with_options(event_source, span, options);
Ok(
crate::providers::internal::sse_transport::stamp_terminal_request_id(
stream,
request_id_slot,
Ext::REQUEST_ID_HEADER,
|response, id| response.provider_request_id = Some(id),
),
)
}
pub(crate) async fn stream(
&self,
completion_request: crate::completion::CompletionRequest,
) -> Result<streaming::StreamingCompletionResponse, CompletionError> {
let raw = self.raw_stream(completion_request).await?;
Ok(normalize_responses_stream(Ext::PROVIDER_NAME, raw))
}
}
#[cfg(test)]
mod tests {
use super::{
ContentPartChunkPart, ItemChunk, ItemChunkKind, RawChoiceAccumulator,
ResponsesStreamOptions, StreamingCompletionChunk, classify_responses_frame,
raw_choices_from_sse_body, reasoning_end_from_done_item,
};
use crate::completion::CompletionModel;
use crate::message::ReasoningContent;
use crate::providers::internal::openai_chat_completions_compatible::test_support::{
sse_bytes_from_data_lines, sse_bytes_from_json_events,
};
use crate::providers::internal::wire::WireEvent;
use crate::providers::openai::responses_api::{
AdditionalParameters, CompletionResponse, IncompleteDetailsReason, OutputTokensDetails,
ReasoningSummary, ResponseError, ResponseObject, ResponseStatus, ResponsesUsage,
};
use crate::streaming::{RawStreamingChoice, StreamedAssistantContent};
use crate::test_utils::MockStreamingClient;
use crate::{client::CompletionClient, providers::openai};
use futures::StreamExt;
use serde_json::{self, json};
#[test]
fn classify_known_event_decodes() {
let frame = json!({
"type": "response.output_text.delta",
"item_id": "msg_1",
"output_index": 0,
"content_index": 0,
"sequence_number": 1,
"delta": "hi",
})
.to_string();
assert!(matches!(
classify_responses_frame(&frame),
WireEvent::Known(StreamingCompletionChunk::Delta(_))
));
}
#[test]
fn classify_unknown_event_type_is_unknown() {
let frame = json!({
"type": "response.web_search_call.searching",
"output_index": 0,
"sequence_number": 1,
})
.to_string();
assert!(matches!(
classify_responses_frame(&frame),
WireEvent::Unknown { event_type, .. } if event_type == "response.web_search_call.searching"
));
}
#[test]
fn classify_reasoning_text_done_is_known_and_decodes() {
let frame = json!({
"type": "response.reasoning_text.done",
"item_id": "rs_1",
"output_index": 0,
"content_index": 0,
"sequence_number": 7,
"text": "the model's raw chain of thought",
})
.to_string();
let event = classify_responses_frame(&frame);
assert!(
!matches!(event, WireEvent::Unknown { .. }),
"the tag must be in the known-event set: {event:?}"
);
assert!(
!matches!(event, WireEvent::Corrupt(_)),
"a known tag with no matching ItemChunkKind variant decodes to Corrupt, which the \
driver surfaces as an in-band Err — worse than the warn it replaced: {event:?}"
);
assert!(matches!(
event,
WireEvent::Known(StreamingCompletionChunk::Delta(chunk))
if matches!(chunk.data, ItemChunkKind::ReasoningTextDone(_))
));
}
#[test]
fn reasoning_text_done_emits_nothing() {
let mut accumulator = RawChoiceAccumulator::new(ResponsesUsage::new());
let chunk: ItemChunk = serde_json::from_value(json!({
"type": "response.reasoning_text.done",
"item_id": "rs_1",
"output_index": 0,
"content_index": 0,
"sequence_number": 7,
"text": "the model's raw chain of thought",
}))
.expect("reasoning text done event should deserialize");
let emitted = accumulator.decode_item_chunk(chunk, ResponsesStreamOptions::strict());
assert!(
emitted.is_empty(),
"the done restatement must not re-emit the reasoning text: {emitted:?}"
);
}
#[test]
fn classify_invalid_json_is_corrupt() {
assert!(matches!(
classify_responses_frame("{not json"),
WireEvent::Corrupt(_)
));
}
#[test]
fn classify_known_event_with_defective_payload_is_corrupt() {
let frame = json!({
"type": "response.output_text.delta",
"item_id": "msg_1",
"output_index": 0,
"content_index": 0,
"sequence_number": 1,
"delta": 42,
})
.to_string();
assert!(matches!(
classify_responses_frame(&frame),
WireEvent::Corrupt(_)
));
}
#[test]
fn classify_defective_known_content_part_is_corrupt() {
let frame = json!({
"type": "response.content_part.added",
"item_id": "msg_1",
"output_index": 0,
"content_index": 0,
"sequence_number": 1,
"part": {"type": "output_text", "text": 42},
})
.to_string();
assert!(matches!(
classify_responses_frame(&frame),
WireEvent::Corrupt(_)
));
}
#[test]
fn content_part_known_tag_decodes() {
let part: ContentPartChunkPart =
serde_json::from_value(json!({"type": "output_text", "text": "hi"})).unwrap();
assert!(matches!(part, ContentPartChunkPart::OutputText { text } if text == "hi"));
}
#[test]
fn content_part_known_tag_with_defective_payload_errors() {
let result = serde_json::from_value::<ContentPartChunkPart>(
json!({"type": "output_text", "text": 42}),
);
assert!(result.is_err());
let result = serde_json::from_value::<ContentPartChunkPart>(
json!({"type": "summary_text", "text": 42}),
);
assert!(result.is_err());
}
#[test]
fn content_part_non_string_type_errors() {
let result =
serde_json::from_value::<ContentPartChunkPart>(json!({"type": 42, "text": "hi"}));
assert!(result.is_err());
let result =
serde_json::from_value::<ContentPartChunkPart>(json!({"type": null, "text": "hi"}));
assert!(result.is_err());
}
#[test]
fn content_part_duplicate_type_key_dispatches_on_the_last_occurrence() {
let part: ContentPartChunkPart =
serde_json::from_str(r#"{"type":"bogus","type":"output_text","text":"hi"}"#).unwrap();
assert!(matches!(part, ContentPartChunkPart::OutputText { text } if text == "hi"));
}
#[test]
fn content_part_unknown_tag_is_preserved_verbatim() {
let wire = json!({"type": "refusal", "refusal": "no"});
let part: ContentPartChunkPart = serde_json::from_value(wire.clone()).unwrap();
let ContentPartChunkPart::Unknown(value) = &part else {
panic!("unmodeled part tag must fall back to Unknown");
};
assert_eq!(value, &wire);
assert_eq!(serde_json::to_value(&part).unwrap(), wire);
}
fn sample_response(status: ResponseStatus) -> CompletionResponse {
CompletionResponse {
id: "resp_123".to_string(),
object: ResponseObject::Response,
provider_request_id: None,
created_at: 0,
status,
error: None,
incomplete_details: None,
instructions: None,
max_output_tokens: None,
model: "gpt-5.4".to_string(),
provider_reasoning: None,
reasoning_metadata: None,
reasoning_context: None,
usage: None,
output: Vec::new(),
tools: Vec::new(),
additional_parameters: AdditionalParameters::default(),
}
}
async fn first_error_from_event(
event: serde_json::Value,
) -> crate::completion::CompletionError {
let client = openai::Client::builder()
.http_client(MockStreamingClient {
sse_bytes: sse_bytes_from_json_events(&[event]),
})
.api_key("test-key")
.build()
.expect("client should build");
let model = client.completion_model("gpt-5.4");
let request = model.completion_request("hello").build();
let mut stream = model.stream(request).await.expect("stream should start");
stream
.next()
.await
.expect("stream should yield an item")
.expect_err("stream should surface a provider error")
}
async fn final_response_from_event(
event: serde_json::Value,
) -> super::StreamingCompletionResponse {
let client = openai::Client::builder()
.http_client(MockStreamingClient {
sse_bytes: sse_bytes_from_json_events(&[event]),
})
.api_key("test-key")
.build()
.expect("client should build");
let model = client.completion_model("gpt-5.4");
let request = model.completion_request("hello").build();
let mut stream = model
.raw_stream(request)
.await
.expect("stream should start");
while let Some(item) = stream.next().await {
if let RawStreamingChoice::FinalResponse(response) =
item.expect("completed stream should not error")
{
return response;
}
}
panic!("stream should yield a final response");
}
async fn stream_final_from_event(event: serde_json::Value) -> crate::streaming::StreamFinal {
let client = openai::Client::builder()
.http_client(MockStreamingClient {
sse_bytes: sse_bytes_from_json_events(&[event]),
})
.api_key("test-key")
.build()
.expect("client should build");
let model = client.completion_model("gpt-5.4");
let request = model.completion_request("hello").build();
let mut stream = model.stream(request).await.expect("stream should start");
while let Some(item) = stream.next().await {
if let StreamedAssistantContent::Final(response) =
item.expect("completed stream should not error")
{
return response;
}
}
panic!("stream should yield a final response");
}
#[test]
fn parse_sse_completion_body_preserves_error_payloads() {
let mut response = sample_response(ResponseStatus::Failed);
response.error = Some(ResponseError {
code: "server_error".to_string(),
message: "response failed".to_string(),
});
let events = [
json!({
"type": "response.failed",
"sequence_number": 1,
"response": response,
}),
json!({
"type": "error",
"error": {
"message": "boom",
"code": "server_error",
"type": "server_error"
}
}),
];
for event in events {
let payload = serde_json::to_string(&event).expect("event should serialize");
let body = format!("data: {payload}\n");
let err = super::parse_sse_completion_body(&body, "ChatGPT")
.expect_err("error payload should surface as provider response");
assert!(matches!(
err,
crate::completion::CompletionError::ProviderResponse(_)
));
assert_eq!(err.provider_response_status(), None);
assert_eq!(err.provider_response_body(), Some(payload.as_str()));
}
}
#[test]
fn reasoning_done_item_fuses_summary_content_and_encrypted_into_one_end() {
let summary = vec![
ReasoningSummary::SummaryText {
text: "step 1".to_string(),
},
ReasoningSummary::SummaryText {
text: "step 2".to_string(),
},
];
let content = vec!["private reasoning".to_string()];
let end = reasoning_end_from_done_item(
&crate::streaming::StreamPartId::wire("rs_1"),
crate::streaming::WireId::new("rs_1").as_ref(),
summary,
content,
Some("enc_blob".to_string()),
);
let Some(RawStreamingChoice::ReasoningEnd {
id,
reasoning: Some(reasoning),
signature: None,
wire_sent: true,
}) = end
else {
panic!("expected one wire-sent ReasoningEnd restatement");
};
assert_eq!(id, crate::streaming::StreamPartId::wire("rs_1"));
assert_eq!(reasoning.id.as_deref(), Some("rs_1"));
assert_eq!(
reasoning.content,
vec![
ReasoningContent::Summary("step 1".to_string()),
ReasoningContent::Summary("step 2".to_string()),
ReasoningContent::Text {
text: "private reasoning".to_string(),
signature: None,
},
ReasoningContent::Encrypted("enc_blob".to_string()),
]
);
}
#[test]
fn reasoning_output_item_done_emits_reasoning_text_content() {
let body = format!(
"data: {}\n",
json!({
"type": "response.output_item.done",
"output_index": 0,
"sequence_number": 1,
"item": {
"type": "reasoning",
"id": "rs_text_1",
"summary": [],
"content": [{ "type": "reasoning_text", "text": "visible reasoning" }],
"status": "completed"
},
})
);
let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
.expect("sse body should decode");
assert!(matches!(
choices.first(),
Some(RawStreamingChoice::ReasoningEnd {
id,
reasoning: Some(reasoning),
wire_sent: true,
..
}) if id == &crate::streaming::StreamPartId::wire("rs_text_1")
&& reasoning.content
== vec![ReasoningContent::Text {
text: "visible reasoning".to_string(),
signature: None,
}]
));
}
#[test]
fn envelope_less_reasoning_then_text_decodes_without_violation() {
let body = format!(
"data: {}\ndata: {}\ndata: {}\n",
json!({
"type": "response.reasoning_summary_text.delta",
"output_index": 0,
"summary_index": 0,
"sequence_number": 1,
"delta": "thinking",
}),
json!({
"type": "response.output_item.done",
"output_index": 0,
"sequence_number": 2,
"item": {
"type": "reasoning",
"id": "",
"summary": [{ "type": "summary_text", "text": "thinking, complete" }],
"status": "completed"
},
}),
json!({
"type": "response.output_text.delta",
"item_id": "msg_1",
"output_index": 1,
"content_index": 0,
"sequence_number": 3,
"delta": "the answer",
}),
);
let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
.expect("sse body should decode without a sequence-law violation");
assert!(choices.iter().any(
|choice| matches!(choice, RawStreamingChoice::Message(text) if text == "the answer")
));
}
#[test]
fn reasoning_text_delta_emits_reasoning_delta() {
let body = format!(
"data: {}\n",
json!({
"type": "response.reasoning_text.delta",
"item_id": "rs_delta_1",
"output_index": 0,
"content_index": 0,
"sequence_number": 1,
"delta": "thinking",
})
);
let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
.expect("sse body should decode");
assert!(matches!(
choices.first(),
Some(RawStreamingChoice::ReasoningDelta { id, provider_id: _, reasoning })
if id == &crate::streaming::StreamPartId::wire("rs_delta_1") && reasoning == "thinking"
));
}
#[test]
fn unknown_output_item_surfaces_as_raw_unknown_choice() {
let item = json!({
"type": "web_search_call",
"id": "ws_001",
"status": "completed",
"action": { "type": "search", "queries": ["rig framework"] },
});
let body = format!(
"data: {}\n",
json!({
"type": "response.output_item.done",
"output_index": 0,
"sequence_number": 1,
"item": item,
})
);
let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
.expect("sse body should decode");
let unknown = choices.iter().find_map(|choice| match choice {
RawStreamingChoice::Unknown(value) => Some(value),
_ => None,
});
assert_eq!(
unknown,
Some(&item.clone().into()),
"the raw web_search_call item should reach the consumer verbatim",
);
}
#[test]
fn reasoning_done_item_without_encrypted_emits_summary_only() {
let summary = vec![ReasoningSummary::SummaryText {
text: "only summary".to_string(),
}];
let end = reasoning_end_from_done_item(
&crate::streaming::StreamPartId::wire("rs_2"),
crate::streaming::WireId::new("rs_2").as_ref(),
summary,
Vec::new(),
None,
);
let Some(RawStreamingChoice::ReasoningEnd {
id,
reasoning: Some(reasoning),
..
}) = end
else {
panic!("expected one ReasoningEnd restatement");
};
assert_eq!(id, crate::streaming::StreamPartId::wire("rs_2"));
assert_eq!(
reasoning.content,
vec![ReasoningContent::Summary("only summary".to_string())]
);
}
#[test]
fn empty_encrypted_reasoning_is_not_emitted() {
let content = vec!["visible reasoning".to_string()];
let end = reasoning_end_from_done_item(
&crate::streaming::StreamPartId::wire("rs_1"),
crate::streaming::WireId::new("rs_1").as_ref(),
Vec::new(),
content,
Some(String::new()),
);
let Some(RawStreamingChoice::ReasoningEnd {
reasoning: Some(reasoning),
..
}) = end
else {
panic!("expected one ReasoningEnd restatement");
};
assert_eq!(
reasoning.content,
vec![ReasoningContent::Text {
text: "visible reasoning".to_string(),
signature: None,
}],
"an empty encrypted payload contributes no block"
);
assert!(
reasoning_end_from_done_item(
&crate::streaming::StreamPartId::wire("rs_1"),
crate::streaming::WireId::new("rs_1").as_ref(),
Vec::new(),
Vec::new(),
Some(String::new()),
)
.is_none()
);
}
#[test]
fn content_part_added_deserializes_snake_case_part_type() {
let chunk: StreamingCompletionChunk = serde_json::from_value(json!({
"type": "response.content_part.added",
"item_id": "msg_1",
"output_index": 0,
"content_index": 0,
"sequence_number": 3,
"part": {
"type": "output_text",
"text": "hello"
}
}))
.expect("content part event should deserialize");
assert!(matches!(
chunk,
StreamingCompletionChunk::Delta(chunk)
if matches!(
chunk.data,
ItemChunkKind::ContentPartAdded(_)
)
));
}
#[test]
fn content_part_done_deserializes_snake_case_part_type() {
let chunk: StreamingCompletionChunk = serde_json::from_value(json!({
"type": "response.content_part.done",
"item_id": "msg_1",
"output_index": 0,
"content_index": 0,
"sequence_number": 4,
"part": {
"type": "summary_text",
"text": "done"
}
}))
.expect("content part done event should deserialize");
assert!(matches!(
chunk,
StreamingCompletionChunk::Delta(chunk)
if matches!(
chunk.data,
ItemChunkKind::ContentPartDone(_)
)
));
}
#[test]
fn reasoning_summary_part_added_deserializes_snake_case_part_type() {
let chunk: StreamingCompletionChunk = serde_json::from_value(json!({
"type": "response.reasoning_summary_part.added",
"item_id": "rs_1",
"output_index": 0,
"summary_index": 0,
"sequence_number": 5,
"part": {
"type": "summary_text",
"text": "step 1"
}
}))
.expect("reasoning summary part event should deserialize");
assert!(matches!(
chunk,
StreamingCompletionChunk::Delta(chunk)
if matches!(
chunk.data,
ItemChunkKind::ReasoningSummaryPartAdded(_)
)
));
}
#[test]
fn reasoning_summary_part_done_deserializes_snake_case_part_type() {
let chunk: StreamingCompletionChunk = serde_json::from_value(json!({
"type": "response.reasoning_summary_part.done",
"item_id": "rs_1",
"output_index": 0,
"summary_index": 0,
"sequence_number": 6,
"part": {
"type": "summary_text",
"text": "step 2"
}
}))
.expect("reasoning summary part done event should deserialize");
assert!(matches!(
chunk,
StreamingCompletionChunk::Delta(chunk)
if matches!(
chunk.data,
ItemChunkKind::ReasoningSummaryPartDone(_)
)
));
}
#[tokio::test]
async fn response_failed_chunk_surfaces_provider_error_without_empty_code_prefix() {
let mut response = sample_response(ResponseStatus::Failed);
response.error = Some(ResponseError {
code: String::new(),
message: "maximum context length exceeded".to_string(),
});
let event = json!({
"type": "response.failed",
"sequence_number": 1,
"response": response,
});
let err = first_error_from_event(event).await;
assert!(matches!(
err,
crate::completion::CompletionError::ProviderResponse(_)
));
assert_eq!(err.provider_response_status(), None);
assert!(err.provider_response_body().is_some_and(|body| {
body.contains("response.failed") && body.contains("maximum context length exceeded")
}));
}
#[tokio::test]
async fn response_failed_chunk_surfaces_provider_error_with_code_prefix() {
let mut response = sample_response(ResponseStatus::Failed);
response.error = Some(ResponseError {
code: "context_length_exceeded".to_string(),
message: "maximum context length exceeded".to_string(),
});
let event = json!({
"type": "response.failed",
"sequence_number": 1,
"response": response,
});
let err = first_error_from_event(event).await;
assert!(matches!(
err,
crate::completion::CompletionError::ProviderResponse(_)
));
assert_eq!(err.provider_response_status(), None);
assert!(err.provider_response_body().is_some_and(|body| {
body.contains("response.failed")
&& body.contains("context_length_exceeded")
&& body.contains("maximum context length exceeded")
}));
}
#[tokio::test]
async fn response_incomplete_chunk_is_a_successful_terminal_with_mapped_finish_reason() {
let text_delta = json!({
"type": "response.output_text.delta",
"content_index": 0,
"delta": "partial",
"item_id": "msg_incomplete_1",
"output_index": 0,
"sequence_number": 1,
});
let mut response = sample_response(ResponseStatus::Incomplete);
response.incomplete_details = Some(IncompleteDetailsReason {
reason: "max_output_tokens".to_string(),
});
response.usage = Some(ResponsesUsage {
input_tokens: 10,
input_tokens_details: None,
output_tokens: 5,
output_tokens_details: Some(OutputTokensDetails {
reasoning_tokens: 0,
}),
total_tokens: 15,
});
let incomplete = json!({
"type": "response.incomplete",
"sequence_number": 2,
"response": response,
});
let client = openai::Client::builder()
.http_client(MockStreamingClient {
sse_bytes: sse_bytes_from_json_events(&[text_delta, incomplete]),
})
.api_key("test-key")
.build()
.expect("client should build");
let model = client.completion_model("gpt-5.4");
let request = model.completion_request("hello").build();
let mut stream = model.stream(request).await.expect("stream should start");
let mut text = String::new();
let mut final_response = None;
while let Some(item) = stream.next().await {
match item.expect("incomplete stream should not error") {
StreamedAssistantContent::Text(delta) => text.push_str(&delta.text),
StreamedAssistantContent::Final(response) => final_response = Some(response),
_ => {}
}
}
assert_eq!(text, "partial");
let final_response = final_response.expect("stream should yield a final response");
assert_eq!(
final_response.finish_reason,
Some(crate::completion::FinishReason::Length)
);
assert_eq!(final_response.usage.input_tokens, 10);
assert_eq!(final_response.usage.output_tokens, 5);
assert_eq!(final_response.usage.total_tokens, 15);
}
#[tokio::test]
async fn multi_block_reasoning_done_item_yields_one_part() {
let reasoning_done = json!({
"type": "response.output_item.done",
"output_index": 0,
"sequence_number": 1,
"item": {
"type": "reasoning",
"id": "rs_1",
"summary": [
{"type": "summary_text", "text": "step 1"},
{"type": "summary_text", "text": "step 2"}
],
"content": [],
"encrypted_content": "enc_blob"
}
});
let completed = json!({
"type": "response.completed",
"sequence_number": 2,
"response": sample_response(ResponseStatus::Completed),
});
let client = openai::Client::builder()
.http_client(MockStreamingClient {
sse_bytes: sse_bytes_from_json_events(&[reasoning_done, completed]),
})
.api_key("test-key")
.build()
.expect("client should build");
let model = client.completion_model("gpt-5.4");
let request = model.completion_request("hello").build();
let mut stream = model.stream(request).await.expect("stream should start");
let mut completed_reasoning = Vec::new();
while let Some(item) = stream.next().await {
if let StreamedAssistantContent::Reasoning { reasoning, .. } =
item.expect("stream items should be ok")
{
completed_reasoning.push(reasoning);
}
}
assert_eq!(
completed_reasoning.len(),
1,
"one done item must complete exactly one reasoning part, got {completed_reasoning:?}"
);
let reasoning = completed_reasoning.first().expect("one part");
assert_eq!(reasoning.id.as_deref(), Some("rs_1"));
assert_eq!(
reasoning.content,
vec![
ReasoningContent::Summary("step 1".to_string()),
ReasoningContent::Summary("step 2".to_string()),
ReasoningContent::Encrypted("enc_blob".to_string()),
],
"every block survives, in wire order, inside the one part"
);
let choice = stream.choice;
let reasoning_parts = choice
.iter()
.filter(|content| matches!(content, crate::message::AssistantContent::Reasoning(_)))
.count();
assert_eq!(
reasoning_parts, 1,
"history must carry one reasoning part per rs_* id, got {choice:?}"
);
}
#[tokio::test]
async fn response_failed_flushes_delivered_tool_calls_before_the_error() {
let tool_call_done = json!({
"type": "response.output_item.done",
"output_index": 0,
"sequence_number": 1,
"item": {
"type": "function_call",
"id": "fc_123",
"arguments": "{}",
"call_id": "call_123",
"name": "example_tool",
"status": "completed"
}
});
let mut response = sample_response(ResponseStatus::Failed);
response.error = Some(ResponseError {
code: "server_error".to_string(),
message: "response stream failed".to_string(),
});
let failed = json!({
"type": "response.failed",
"sequence_number": 2,
"response": response,
});
let client = openai::Client::builder()
.http_client(MockStreamingClient {
sse_bytes: sse_bytes_from_json_events(&[tool_call_done, failed]),
})
.api_key("test-key")
.build()
.expect("client should build");
let model = client.completion_model("gpt-5.4");
let request = model.completion_request("hello").build();
let mut stream = model.stream(request).await.expect("stream should start");
let tool_call = match stream
.next()
.await
.expect("stream should yield the flushed tool call")
.expect("the flushed tool call must precede the terminal error")
{
StreamedAssistantContent::ToolCall { tool_call, .. } => tool_call,
other => panic!("expected the flushed tool call first, got {other:?}"),
};
assert_eq!(tool_call.id, "call_123");
let provider = tool_call.provider.as_ref().expect("provider ids are kept");
assert_eq!(provider.call_id, "call_123");
assert_eq!(provider.item_id.as_deref(), Some("fc_123"));
assert_eq!(tool_call.function.name, "example_tool");
let err = stream
.next()
.await
.expect("stream should yield an item")
.expect_err("stream should surface a provider error");
assert!(matches!(
err,
crate::completion::CompletionError::ProviderResponse(_)
));
assert_eq!(err.provider_response_status(), None);
assert!(err.provider_response_body().is_some_and(|body| {
body.contains("response.failed") && body.contains("response stream failed")
}));
assert!(
stream.next().await.is_none(),
"stream should terminate immediately after the terminal error"
);
assert!(stream.response.is_none());
}
#[tokio::test]
async fn transport_error_flushes_delivered_tool_calls_before_the_error() {
use crate::http_client::sse::GenericEventSource;
use crate::test_utils::SequencedStreamingHttpClient;
let tool_call_done = json!({
"type": "response.output_item.done",
"output_index": 0,
"sequence_number": 1,
"item": {
"type": "function_call",
"id": "fc_123",
"arguments": "{}",
"call_id": "call_123",
"name": "example_tool",
"status": "completed"
}
});
let chunks = vec![
Ok(sse_bytes_from_data_lines([tool_call_done.to_string()])),
Err(crate::http_client::Error::InvalidStatusCodeWithMessage(
http::StatusCode::BAD_GATEWAY,
r#"{"error":{"message":"upstream unavailable"}}"#.to_string(),
)),
];
let client = SequencedStreamingHttpClient::new(chunks);
let req = http::Request::builder()
.method("POST")
.uri("http://localhost/v1/responses")
.body(Vec::new())
.expect("request should build");
let event_source = GenericEventSource::new(client, req);
let mut stream = super::normalize_responses_stream(
"openai",
super::raw_stream_from_event_source(event_source, tracing::Span::none()),
);
match stream
.next()
.await
.expect("stream should yield the flushed tool call")
.expect("the flushed tool call must precede the transport error")
{
StreamedAssistantContent::ToolCall { tool_call, .. } => {
assert_eq!(tool_call.id, "call_123");
let provider = tool_call.provider.as_ref().expect("provider ids are kept");
assert_eq!(provider.item_id.as_deref(), Some("fc_123"));
}
other => panic!("expected the flushed tool call first, got {other:?}"),
}
let err = stream
.next()
.await
.expect("stream should yield the transport error")
.expect_err("the transport failure must reach the consumer");
assert_eq!(
err.provider_response_status(),
Some(http::StatusCode::BAD_GATEWAY)
);
assert!(
stream.next().await.is_none(),
"nothing may follow the terminal error"
);
assert!(stream.response.is_none());
}
#[tokio::test]
async fn known_terminal_with_malformed_usage_surfaces_error_without_terminal() {
let mut event = json!({
"type": "response.completed",
"sequence_number": 1,
"response": sample_response(ResponseStatus::Completed),
});
event["response"]["usage"] = json!("banana");
let client = openai::Client::builder()
.http_client(MockStreamingClient {
sse_bytes: sse_bytes_from_json_events(&[event]),
})
.api_key("test-key")
.build()
.expect("client should build");
let model = client.completion_model("gpt-5.4");
let request = model.completion_request("hello").build();
let mut stream = model.stream(request).await.expect("stream should start");
let mut saw_error = false;
let mut saw_final = false;
while let Some(item) = stream.next().await {
match item {
Ok(StreamedAssistantContent::Final(_)) => saw_final = true,
Ok(other) => panic!("unexpected stream item: {other:?}"),
Err(err) => {
assert!(
matches!(err, crate::completion::CompletionError::JsonError(_)),
"expected a parse error item, got {err:?}"
);
saw_error = true;
}
}
}
assert!(saw_error, "the corrupt terminal must surface as an error");
assert!(
!saw_final,
"a terminal that failed to parse must not produce a terminal record"
);
assert!(stream.response.is_none());
}
#[tokio::test]
async fn unknown_event_type_is_skipped_and_stream_completes() {
let unknown = json!({
"type": "response.rocket_launch",
"payload": { "count": 3 }
});
let completed = json!({
"type": "response.completed",
"sequence_number": 2,
"response": sample_response(ResponseStatus::Completed),
});
let client = openai::Client::builder()
.http_client(MockStreamingClient {
sse_bytes: sse_bytes_from_json_events(&[unknown, completed]),
})
.api_key("test-key")
.build()
.expect("client should build");
let model = client.completion_model("gpt-5.4");
let request = model.completion_request("hello").build();
let mut stream = model.stream(request).await.expect("stream should start");
let mut saw_final = false;
while let Some(item) = stream.next().await {
if let StreamedAssistantContent::Final(_) =
item.expect("unknown event types must not surface as errors")
{
saw_final = true;
}
}
assert!(
saw_final,
"the genuine terminal must still complete the stream"
);
}
#[tokio::test]
async fn refusal_content_part_frames_are_no_ops_and_refusal_text_streams() {
let part_added = json!({
"type": "response.content_part.added",
"item_id": "msg_1",
"output_index": 0,
"content_index": 0,
"sequence_number": 1,
"part": { "type": "refusal", "refusal": "" }
});
let refusal_delta = json!({
"type": "response.refusal.delta",
"item_id": "msg_1",
"output_index": 0,
"content_index": 0,
"sequence_number": 2,
"delta": "I can't help with that."
});
let part_done = json!({
"type": "response.content_part.done",
"item_id": "msg_1",
"output_index": 0,
"content_index": 0,
"sequence_number": 3,
"part": { "type": "refusal", "refusal": "I can't help with that." }
});
let reasoning_part = json!({
"type": "response.content_part.added",
"item_id": "rs_1",
"output_index": 1,
"content_index": 0,
"sequence_number": 4,
"part": { "type": "reasoning_text", "text": "" }
});
let completed = json!({
"type": "response.completed",
"sequence_number": 5,
"response": sample_response(ResponseStatus::Completed),
});
let client = openai::Client::builder()
.http_client(MockStreamingClient {
sse_bytes: sse_bytes_from_json_events(&[
part_added,
refusal_delta,
part_done,
reasoning_part,
completed,
]),
})
.api_key("test-key")
.build()
.expect("client should build");
let model = client.completion_model("gpt-5.4");
let request = model.completion_request("hello").build();
let mut stream = model.stream(request).await.expect("stream should start");
let mut texts = Vec::new();
let mut saw_final = false;
while let Some(item) = stream.next().await {
match item.expect("content-part frames must not surface as errors") {
StreamedAssistantContent::Text(text) => texts.push(text.text),
StreamedAssistantContent::Final(_) => saw_final = true,
_ => {}
}
}
assert_eq!(texts, ["I can't help with that."]);
assert!(saw_final, "the terminal must still arrive");
}
#[tokio::test]
async fn truncated_stream_does_not_synthesize_a_terminal_record() {
use crate::providers::internal::openai_chat_completions_compatible::test_support::sse_bytes_from_json_events;
use crate::test_utils::MockStreamingClient;
let deltas = [
json!({
"type": "response.output_text.delta",
"output_index": 0,
"content_index": 0,
"sequence_number": 1,
"delta": "hel"
}),
json!({
"type": "response.output_text.delta",
"output_index": 0,
"content_index": 0,
"sequence_number": 2,
"delta": "lo"
}),
];
let client = openai::Client::builder()
.http_client(MockStreamingClient {
sse_bytes: sse_bytes_from_json_events(&deltas),
})
.api_key("test-key")
.build()
.expect("client should build");
let model = client.completion_model("gpt-5.4");
let request = model.completion_request("hello").build();
let mut stream = model.stream(request).await.expect("stream should start");
let mut texts = Vec::new();
let mut saw_terminal = false;
while let Some(item) = stream.next().await {
match item.expect("stream item should be Ok") {
StreamedAssistantContent::Text(text) => texts.push(text.text),
StreamedAssistantContent::Final(_) => saw_terminal = true,
_ => {}
}
}
assert_eq!(texts, ["hel", "lo"]);
assert!(
!saw_terminal,
"EOF without response.completed must not synthesize a terminal record"
);
assert!(stream.response.is_none());
}
#[tokio::test]
async fn streaming_error_event_preserves_full_payload_in_live_loop() {
use crate::providers::internal::openai_chat_completions_compatible::test_support::sse_bytes_from_json_events;
use crate::test_utils::MockStreamingClient;
let payload = json!({
"type": "error",
"error": {
"message": "boom",
"code": "server_error",
"type": "server_error"
}
});
let client = openai::Client::builder()
.http_client(MockStreamingClient {
sse_bytes: sse_bytes_from_json_events(&[payload]),
})
.api_key("test-key")
.build()
.expect("client should build");
let model = client.completion_model("gpt-5.4");
let request = model.completion_request("hello").build();
let mut stream = model.stream(request).await.expect("stream should start");
let err = stream
.next()
.await
.expect("stream should yield an item")
.expect_err("stream should surface a provider response error");
assert_eq!(err.provider_response_status(), None);
assert!(
err.provider_response_body().is_some_and(|body| {
body.contains("\"type\":\"error\"") && body.contains("boom")
})
);
assert!(
stream.next().await.is_none(),
"stream should terminate after error event"
);
}
#[tokio::test]
async fn streaming_http_non_success_preserves_status_and_body() {
use crate::http_client::sse::GenericEventSource;
use crate::test_utils::HttpErrorStreamingClient;
let body = r#"{"error":{"message":"quota exceeded"}}"#;
let client = HttpErrorStreamingClient::new(http::StatusCode::TOO_MANY_REQUESTS, body);
let req = http::Request::builder()
.method("POST")
.uri("http://localhost/v1/responses")
.body(Vec::new())
.expect("request should build");
let event_source = GenericEventSource::new(client, req);
let span = tracing::Span::none();
let mut stream = super::normalize_responses_stream(
"openai",
super::raw_stream_from_event_source(event_source, span),
);
let err = stream
.next()
.await
.expect("stream should yield transport error")
.expect_err("HTTP non-success should surface as a stream error");
assert_eq!(
err.provider_response_status(),
Some(http::StatusCode::TOO_MANY_REQUESTS)
);
assert_eq!(err.provider_response_body(), Some(body));
assert_eq!(
err.provider_response_json().expect("valid JSON body"),
Some(serde_json::json!({"error": {"message": "quota exceeded"}}))
);
assert!(
stream.next().await.is_none(),
"stream should terminate after HTTP non-success"
);
}
#[test]
fn corrupt_known_frame_fails_the_buffered_body() {
let corrupt = json!({
"type": "response.output_text.delta",
"delta": 42
});
let completed = json!({
"type": "response.completed",
"sequence_number": 2,
"response": sample_response(ResponseStatus::Completed),
});
let body = format!("data: {corrupt}\ndata: {completed}\n");
let err = raw_choices_from_sse_body(&body, ResponsesUsage::new())
.expect_err("a corrupt known frame must fail the buffered decode");
assert!(
err.to_string().contains("response.output_text.delta"),
"the error should name the malformed event, got: {err}"
);
let body = format!("data: {{not json\ndata: {completed}\n");
raw_choices_from_sse_body(&body, ResponsesUsage::new())
.expect_err("invalid JSON must fail the buffered decode");
let unknown = json!({ "type": "response.rocket_launch", "count": 3 });
let body = format!("data: {unknown}\ndata: {completed}\n");
let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
.expect("unknown event types must stay skippable");
assert!(
choices
.iter()
.any(|choice| matches!(choice, RawStreamingChoice::FinalResponse(_))),
"the genuine terminal must still be recorded"
);
}
#[test]
fn envelope_less_frames_repair_onto_the_shared_interpreter() {
let completed = json!({
"type": "response.completed",
"response": sample_response(ResponseStatus::Completed),
});
let body = format!(
"data: {}\ndata: {completed}\n",
json!({ "type": "response.output_text.delta", "delta": "hi" })
);
let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
.expect("an envelope-less delta must repair and decode");
assert!(
choices
.iter()
.any(|choice| matches!(choice, RawStreamingChoice::Message(text) if text == "hi"))
);
let body = format!(
"data: {}\ndata: {completed}\n",
json!({ "type": "response.function_call_arguments.delta", "delta": "{}" })
);
let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
.expect("an id-less args delta must repair and decode");
assert!(choices.iter().any(|choice| matches!(
choice,
RawStreamingChoice::ToolCallDelta { id, .. } if id == &crate::streaming::MintKind::Output.for_wire_index(0)
)));
let body = format!(
"data: {}\ndata: {completed}\n",
json!({ "type": "response.output_text.done", "text": "hi" })
);
let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
.expect("an envelope-less done event must repair to the live no-op");
assert!(
choices
.iter()
.any(|choice| matches!(choice, RawStreamingChoice::FinalResponse(_)))
);
let body = format!(
"data: {}\ndata: {completed}\n",
json!({ "type": "response.reasoning_summary_text.delta", "delta": "think" })
);
let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
.expect("an envelope-less summary delta must repair and decode");
assert!(choices.iter().any(|choice| matches!(
choice,
RawStreamingChoice::ReasoningDelta { id, provider_id: _, reasoning }
if id == &crate::streaming::MintKind::Output.for_wire_index(0) && reasoning == "think"
)));
}
#[test]
fn an_unparseable_restatement_is_not_reemitted_over_streamed_fragments() {
let delta = json!({
"type": "response.function_call_arguments.delta",
"item_id": "fc_1",
"output_index": 0,
"sequence_number": 1,
"delta": "{\"x\":481",
});
let done = json!({
"type": "response.output_item.done",
"output_index": 0,
"sequence_number": 2,
"item": {
"type": "function_call",
"id": "fc_1",
"call_id": "call_1",
"name": "add",
"arguments": "{\"x\":481",
"status": "incomplete"
},
});
let body = format!(
"data: {delta}
data: {done}
"
);
let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
.expect("the truncated shape must decode");
let raw_fragments: Vec<&str> = choices
.iter()
.filter_map(|choice| match choice {
RawStreamingChoice::ToolCallDelta {
content: crate::streaming::ToolCallDeltaContent::Delta(fragment),
..
} => Some(fragment.as_str()),
_ => None,
})
.collect();
assert_eq!(
raw_fragments,
vec!["{\"x\":481"],
"the streamed fragment is buffered once; the restatement adds nothing"
);
}
#[test]
fn a_fragmentless_unparseable_restatement_still_reaches_the_buffer() {
let done = json!({
"type": "response.output_item.done",
"output_index": 0,
"sequence_number": 1,
"item": {
"type": "function_call",
"id": "fc_1",
"call_id": "call_1",
"name": "add",
"arguments": "{\"x\":481",
"status": "incomplete"
},
});
let body = format!(
"data: {done}
"
);
let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
.expect("the replayed truncated shape must decode");
let raw_fragments = choices
.iter()
.filter(|choice| {
matches!(
choice,
RawStreamingChoice::ToolCallDelta {
content: crate::streaming::ToolCallDeltaContent::Delta(_),
..
}
)
})
.count();
assert_eq!(raw_fragments, 1, "the raw bytes must reach the buffer once");
}
#[tokio::test]
async fn mixed_id_and_id_less_reasoning_frames_share_one_slot_key() {
let with_id = json!({
"type": "response.reasoning_summary_text.delta",
"item_id": "rs_1",
"output_index": 0,
"summary_index": 0,
"sequence_number": 1,
"delta": "s1 ",
});
let id_less = json!({
"type": "response.reasoning_summary_text.delta",
"output_index": 0,
"summary_index": 0,
"sequence_number": 2,
"delta": "s2",
});
let done = json!({
"type": "response.output_item.done",
"output_index": 0,
"sequence_number": 3,
"item": {
"type": "reasoning",
"id": "rs_1",
"summary": [{"type": "summary_text", "text": "s1 s2"}],
"content": [],
"status": "completed",
},
});
let completed = json!({
"type": "response.completed",
"response": sample_response(ResponseStatus::Completed),
});
let body = format!("data: {with_id}\ndata: {id_less}\ndata: {done}\ndata: {completed}\n");
let raw_choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
.expect("the mixed slot must decode");
let mut keys = std::collections::HashSet::new();
for choice in &raw_choices {
match choice {
RawStreamingChoice::ReasoningDelta { id, .. } => {
keys.insert(id.clone());
}
RawStreamingChoice::ReasoningEnd { id, .. } => {
keys.insert(id.clone());
}
_ => {}
}
}
assert_eq!(
keys.len(),
1,
"one slot, one assembly key — got {keys:?} across {raw_choices:?}"
);
let raw_response = sample_response(ResponseStatus::Completed);
let response =
super::completion_response_from_raw_choices("openai", raw_choices, &raw_response)
.await
.expect("the mixed slot should normalize")
.expect("a reasoning-bearing stream is not empty");
let reasoning_parts = response
.choice
.iter()
.filter(|content| matches!(content, crate::completion::AssistantContent::Reasoning(_)))
.count();
assert_eq!(
reasoning_parts, 1,
"the done item supersedes the one delta-built part; nothing orphans"
);
}
#[tokio::test]
async fn envelope_less_reasoning_deltas_are_superseded_by_their_done_item() {
let delta = json!({ "type": "response.reasoning_summary_text.delta", "delta": "think" });
let done = json!({
"type": "response.output_item.done",
"output_index": 0,
"sequence_number": 2,
"item": {
"type": "reasoning",
"id": "rs_1",
"summary": [{"type": "summary_text", "text": "think"}],
"content": [],
"status": "completed",
},
});
let completed = json!({
"type": "response.completed",
"response": sample_response(ResponseStatus::Completed),
});
let body = format!("data: {delta}\ndata: {done}\ndata: {completed}\n");
let raw_choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
.expect("the envelope-less reasoning replay must decode");
assert!(raw_choices.iter().any(|choice| matches!(
choice,
RawStreamingChoice::ReasoningEnd { id, reasoning: Some(_), .. }
if id == &crate::streaming::MintKind::Output.for_wire_index(0)
)));
let raw_response = sample_response(ResponseStatus::Completed);
let response =
super::completion_response_from_raw_choices("chatgpt", raw_choices, &raw_response)
.await
.expect("replay should normalize")
.expect("a reasoning-bearing replay is not empty");
let reasoning: Vec<_> = response
.choice
.iter()
.filter_map(|content| match content {
crate::completion::AssistantContent::Reasoning(reasoning) => Some(reasoning),
_ => None,
})
.collect();
assert_eq!(
reasoning.len(),
1,
"deltas and their full block must collapse to one reasoning item: {reasoning:?}"
);
let occurrences = reasoning
.iter()
.flat_map(|item| item.content.iter())
.filter(|content| match content {
ReasoningContent::Summary(text) | ReasoningContent::Text { text, .. } => {
text.contains("think")
}
_ => false,
})
.count();
assert_eq!(
occurrences, 1,
"the restated summary must supersede its deltas, not duplicate them"
);
}
#[tokio::test]
async fn same_item_text_resumes_as_one_part_across_interleaved_reasoning() {
let events = [
json!({
"type": "response.output_text.delta",
"item_id": "msg_1",
"output_index": 0,
"content_index": 0,
"sequence_number": 1,
"delta": "hello "
}),
json!({
"type": "response.reasoning_summary_text.delta",
"item_id": "rs_2",
"output_index": 1,
"summary_index": 0,
"sequence_number": 2,
"delta": "because"
}),
json!({
"type": "response.output_text.delta",
"item_id": "msg_1",
"output_index": 0,
"content_index": 0,
"sequence_number": 3,
"delta": "world"
}),
json!({
"type": "response.completed",
"sequence_number": 4,
"response": sample_response(ResponseStatus::Completed),
}),
];
let body = events
.iter()
.map(|event| format!("data: {event}\n"))
.collect::<String>();
let raw_choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
.expect("the interleaved stream must decode");
let starts = raw_choices
.iter()
.filter(|choice| {
matches!(
choice,
RawStreamingChoice::TextStart { id, .. } if id == &crate::streaming::StreamPartId::wire("msg_1")
)
})
.count();
assert_eq!(
starts, 2,
"returning to the same item must re-emit its TextStart: {raw_choices:?}"
);
let raw_response = sample_response(ResponseStatus::Completed);
let response =
super::completion_response_from_raw_choices("openai", raw_choices, &raw_response)
.await
.expect("replay should normalize")
.expect("a text-bearing replay is not empty");
let texts: Vec<_> = response
.choice
.iter()
.filter_map(|content| match content {
crate::completion::AssistantContent::Text(text) => Some(text.text.clone()),
_ => None,
})
.collect();
assert_eq!(
texts,
["hello world"],
"same-item text must aggregate as one part around the reasoning"
);
assert!(
response.choice.iter().any(|content| matches!(
content,
crate::completion::AssistantContent::Reasoning(_)
)),
"the interleaved reasoning must survive"
);
}
#[tokio::test]
async fn mixed_id_and_id_less_events_share_one_slot_key() {
let events = [
json!({
"type": "response.output_item.added",
"output_index": 0,
"sequence_number": 1,
"item": {
"type": "function_call",
"id": "fc_real",
"call_id": "call_a",
"name": "tool_a",
"arguments": "",
"status": "in_progress",
},
}),
json!({
"type": "response.function_call_arguments.delta",
"output_index": 0,
"sequence_number": 2,
"delta": "{\"x\":1}"
}),
json!({
"type": "response.output_item.done",
"output_index": 0,
"sequence_number": 3,
"item": {
"type": "function_call",
"id": "fc_real",
"call_id": "call_a",
"name": "tool_a",
"arguments": "{\"x\":1}",
"status": "completed",
},
}),
json!({
"type": "response.completed",
"sequence_number": 4,
"response": sample_response(ResponseStatus::Completed),
}),
];
let body = events
.iter()
.map(|event| format!("data: {event}\n"))
.collect::<String>();
let raw_choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
.expect("the mixed-id stream must decode");
let mut keys: Vec<crate::streaming::StreamPartId> = raw_choices
.iter()
.filter_map(|choice| match choice {
RawStreamingChoice::ToolCallDelta { id, .. } => Some(id.clone()),
RawStreamingChoice::ToolInputEnd(end) => Some(end.id.clone()),
_ => None,
})
.collect();
keys.dedup();
assert_eq!(
keys,
[crate::streaming::StreamPartId::wire("fc_real")],
"one slot, one assembly key"
);
let raw_response = sample_response(ResponseStatus::Completed);
let response =
super::completion_response_from_raw_choices("openai", raw_choices, &raw_response)
.await
.expect("replay should normalize")
.expect("a tool-bearing replay is not empty");
let call = response
.choice
.iter()
.find_map(|content| match content {
crate::completion::AssistantContent::ToolCall(call) => Some(call.clone()),
_ => None,
})
.expect("the call finalizes");
assert_eq!(call.function.name, "tool_a");
assert_eq!(call.function.arguments, serde_json::json!({"x": 1}));
}
#[tokio::test]
async fn parallel_id_less_function_calls_assemble_distinctly() {
let call_item = |name: &str, call_id: &str, arguments: &str| {
json!({
"type": "function_call",
"call_id": call_id,
"name": name,
"arguments": arguments,
"status": "completed",
})
};
let events = [
json!({
"type": "response.output_item.added",
"output_index": 0,
"sequence_number": 1,
"item": call_item("tool_a", "call_a", ""),
}),
json!({
"type": "response.output_item.added",
"output_index": 1,
"sequence_number": 2,
"item": call_item("tool_b", "call_b", ""),
}),
json!({
"type": "response.function_call_arguments.delta",
"output_index": 0,
"sequence_number": 3,
"delta": "{\"x\":1}"
}),
json!({
"type": "response.function_call_arguments.delta",
"output_index": 1,
"sequence_number": 4,
"delta": "{\"y\":2}"
}),
json!({
"type": "response.output_item.done",
"output_index": 0,
"sequence_number": 5,
"item": call_item("tool_a", "call_a", "{\"x\":1}"),
}),
json!({
"type": "response.output_item.done",
"output_index": 1,
"sequence_number": 6,
"item": call_item("tool_b", "call_b", "{\"y\":2}"),
}),
json!({
"type": "response.completed",
"sequence_number": 7,
"response": sample_response(ResponseStatus::Completed),
}),
];
let body = events
.iter()
.map(|event| format!("data: {event}\n"))
.collect::<String>();
let raw_choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
.expect("the id-less parallel-call stream must decode");
let raw_response = sample_response(ResponseStatus::Completed);
let response =
super::completion_response_from_raw_choices("openai", raw_choices, &raw_response)
.await
.expect("replay should normalize")
.expect("a tool-bearing replay is not empty");
let mut calls: Vec<_> = response
.choice
.iter()
.filter_map(|content| match content {
crate::completion::AssistantContent::ToolCall(call) => Some((
call.function.name.clone(),
call.function.arguments.to_string(),
)),
_ => None,
})
.collect();
calls.sort();
assert_eq!(
calls,
[
("tool_a".to_owned(), json!({"x": 1}).to_string()),
("tool_b".to_owned(), json!({"y": 2}).to_string()),
],
"each id-less slot must assemble its own call"
);
}
#[tokio::test]
async fn a_lost_done_frame_does_not_discard_a_provider_completed_call() {
let events = [
json!({
"type": "response.output_item.added",
"output_index": 0,
"sequence_number": 1,
"item": {
"type": "function_call",
"id": "fc_1",
"call_id": "call_abc",
"name": "get_weather",
"arguments": "",
"status": "in_progress",
},
}),
json!({
"type": "response.function_call_arguments.delta",
"output_index": 0,
"sequence_number": 2,
"delta": "{\"city\":\"Paris\"}"
}),
json!({
"type": "response.completed",
"sequence_number": 3,
"response": sample_response(ResponseStatus::Completed),
}),
];
let body = events
.iter()
.map(|event| format!("data: {event}\n"))
.collect::<String>();
let raw_choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
.expect("the stream must decode");
let raw_response = sample_response(ResponseStatus::Completed);
let response =
super::completion_response_from_raw_choices("openai", raw_choices, &raw_response)
.await
.expect("replay should normalize")
.expect("a tool-bearing replay is not empty");
let calls: Vec<_> = response
.choice
.iter()
.filter_map(|content| match content {
crate::completion::AssistantContent::ToolCall(call) => Some(call),
_ => None,
})
.collect();
assert_eq!(calls.len(), 1, "the provider-completed call must survive");
let call = calls[0];
assert_eq!(call.function.name, "get_weather");
assert_eq!(call.function.arguments, json!({"city": "Paris"}));
let provider = call.provider.as_ref().expect("the wire issued ids");
assert_eq!(provider.call_id, "call_abc");
assert_eq!(provider.item_id.as_deref(), Some("fc_1"));
}
#[tokio::test]
async fn id_less_args_deltas_surface_and_truncation_fabricates_no_call() {
let events = [
json!({
"type": "response.output_item.added",
"output_index": 0,
"sequence_number": 1,
"item": {
"type": "function_call",
"call_id": "call_a",
"name": "tool_a",
"arguments": "",
"status": "in_progress",
},
}),
json!({
"type": "response.function_call_arguments.delta",
"output_index": 0,
"sequence_number": 2,
"delta": "{\"loc\":"
}),
];
let body = events
.iter()
.map(|event| format!("data: {event}\n"))
.collect::<String>();
let raw_choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
.expect("the truncated id-less stream must decode");
assert!(
raw_choices.iter().any(|choice| matches!(
choice,
RawStreamingChoice::ToolCallDelta {
id,
content: crate::streaming::ToolCallDeltaContent::Delta(delta),
} if id == &crate::streaming::MintKind::Output.for_wire_index(0) && delta == "{\"loc\":"
)),
"the id-less args fragment must surface as a delta: {raw_choices:?}"
);
let raw_response = sample_response(ResponseStatus::Completed);
let response =
super::completion_response_from_raw_choices("openai", raw_choices, &raw_response)
.await
.expect("replay should normalize");
assert!(
response.is_none(),
"partial arguments must not fabricate a call: {response:?}"
);
}
#[test]
fn refusal_content_part_frames_do_not_fail_the_buffered_body() {
let part_added = json!({
"type": "response.content_part.added",
"item_id": "msg_1",
"output_index": 0,
"content_index": 0,
"sequence_number": 1,
"part": { "type": "refusal", "refusal": "" }
});
let refusal_delta = json!({
"type": "response.refusal.delta",
"item_id": "msg_1",
"output_index": 0,
"content_index": 0,
"sequence_number": 2,
"delta": "no"
});
let completed = json!({
"type": "response.completed",
"sequence_number": 3,
"response": sample_response(ResponseStatus::Completed),
});
let body = format!(
"data: {part_added}
data: {refusal_delta}
data: {completed}
"
);
let choices = raw_choices_from_sse_body(&body, ResponsesUsage::new())
.expect("refusal content-part frames must not fail the buffered decode");
assert!(
choices
.iter()
.any(|choice| matches!(choice, RawStreamingChoice::Message(text) if text == "no")),
"the refusal text must be delivered"
);
}
#[tokio::test]
async fn terminal_body_message_text_merges_into_reasoning_only_replay() {
use crate::providers::openai::responses_api::Output;
let raw_choices = vec![RawStreamingChoice::ReasoningDelta {
provider_id: crate::streaming::WireId::new("rs_1"),
id: crate::streaming::StreamPartId::wire("rs_1"),
reasoning: "thinking".to_string(),
}];
let mut raw_response = sample_response(ResponseStatus::Completed);
raw_response.output = vec![
serde_json::from_value::<Output>(json!({
"type": "message",
"id": "msg_body_1",
"status": "completed",
"role": "assistant",
"content": [{ "type": "output_text", "annotations": [], "text": "full answer" }]
}))
.expect("output message should deserialize"),
];
let response =
super::completion_response_from_raw_choices("openai", raw_choices, &raw_response)
.await
.expect("replay should normalize")
.expect("a reasoning-bearing replay is not empty");
let text: String = response
.choice
.iter()
.filter_map(|content| match content {
crate::completion::AssistantContent::Text(text) => Some(text.text.as_str()),
_ => None,
})
.collect();
assert_eq!(text, "full answer");
assert!(
response.choice.iter().any(|content| matches!(
content,
crate::completion::AssistantContent::Reasoning(_)
)),
"the replayed reasoning must be kept"
);
assert_eq!(response.message_id.as_deref(), Some("msg_body_1"));
}
#[test]
fn streaming_error_event_preserves_full_payload() {
let payload = r#"{"type":"error","error":{"message":"boom","code":"server_error","type":"server_error"}}"#;
let body = format!("data: {payload}\n");
let err = super::raw_choices_from_sse_body(&body, super::ResponsesUsage::new())
.expect_err("error event should surface as a provider response error");
assert_eq!(err.provider_response_status(), None);
assert_eq!(err.provider_response_body(), Some(payload));
let json = err
.provider_response_json()
.expect("raw body should be valid JSON")
.expect("parsed JSON should be present");
assert_eq!(json["error"]["code"], "server_error");
}
#[tokio::test]
async fn streaming_non_http_transport_error_stays_provider_error() {
use crate::http_client::sse::GenericEventSource;
use crate::test_utils::SequencedStreamingHttpClient;
let chunks = vec![Err(crate::http_client::Error::InvalidContentType(
http::HeaderValue::from_static("application/json"),
))];
let client = SequencedStreamingHttpClient::new(chunks);
let req = http::Request::builder()
.method("POST")
.uri("http://localhost/v1/responses")
.body(Vec::new())
.expect("request should build");
let event_source = GenericEventSource::new(client, req);
let span = tracing::Span::none();
let mut stream = super::normalize_responses_stream(
"openai",
super::raw_stream_from_event_source(event_source, span),
);
let err = stream
.next()
.await
.expect("stream should yield transport error")
.expect_err("non-HTTP transport failure should surface as provider error");
assert_eq!(
err.to_string(),
"ProviderError: Invalid content type was returned: \"application/json\""
);
assert!(matches!(
err,
crate::completion::CompletionError::ProviderError(_)
));
assert_eq!(err.provider_response_body(), None);
assert_eq!(err.provider_response_status(), None);
}
#[tokio::test]
async fn response_completed_chunk_populates_final_usage() {
let mut response = sample_response(ResponseStatus::Completed);
response.usage = Some(ResponsesUsage {
input_tokens: 10,
input_tokens_details: None,
output_tokens: 5,
output_tokens_details: Some(OutputTokensDetails {
reasoning_tokens: 0,
}),
total_tokens: 15,
});
let event = json!({
"type": "response.completed",
"sequence_number": 1,
"response": response,
});
let usage = final_response_from_event(event).await.usage;
assert_eq!(usage.input_tokens, 10);
assert_eq!(usage.output_tokens, 5);
assert_eq!(usage.total_tokens, 15);
}
#[tokio::test]
async fn response_completed_chunk_populates_reasoning_metadata_and_context() {
let response = sample_response(ResponseStatus::Completed);
let mut event = json!({
"type": "response.completed",
"sequence_number": 1,
"response": response,
});
let metadata = json!({
"context": "all_turns",
"effort": "ultra",
"summary": null,
"future_control": true
});
event["response"]["reasoning"] = metadata.clone();
let response = final_response_from_event(event).await;
assert_eq!(response.reasoning_context.as_deref(), Some("all_turns"));
assert_eq!(response.reasoning_metadata.as_ref(), metadata.as_object());
}
#[tokio::test]
async fn terminal_record_normalizes_into_the_stream_final() {
let mut response = sample_response(ResponseStatus::Completed);
response.usage = Some(ResponsesUsage {
input_tokens: 10,
input_tokens_details: None,
output_tokens: 5,
output_tokens_details: None,
total_tokens: 15,
});
let mut event = json!({
"type": "response.completed",
"sequence_number": 1,
"response": response,
});
event["response"]["output"] = json!([{
"type": "message",
"id": "msg_stream_1",
"status": "completed",
"role": "assistant",
"content": [{ "type": "output_text", "annotations": [], "text": "hi" }]
}]);
let final_response = stream_final_from_event(event).await;
assert_eq!(final_response.provider, "openai");
assert_eq!(final_response.model.as_deref(), Some("gpt-5.4"));
assert_eq!(final_response.message_id.as_deref(), Some("msg_stream_1"));
assert_eq!(
final_response.finish_reason,
Some(crate::completion::FinishReason::Stop)
);
assert_eq!(final_response.usage.input_tokens, 10);
assert_eq!(final_response.usage.output_tokens, 5);
assert_eq!(final_response.usage.total_tokens, 15);
}
#[tokio::test]
async fn terminal_record_reports_tool_calls_when_the_stream_called_a_tool() {
let tool_call_done = json!({
"type": "response.output_item.done",
"output_index": 0,
"sequence_number": 1,
"item": {
"type": "function_call",
"id": "fc_123",
"arguments": "{}",
"call_id": "call_123",
"name": "example_tool",
"status": "completed"
}
});
let completed = json!({
"type": "response.completed",
"sequence_number": 2,
"response": sample_response(ResponseStatus::Completed),
});
let client = openai::Client::builder()
.http_client(MockStreamingClient {
sse_bytes: sse_bytes_from_json_events(&[tool_call_done, completed]),
})
.api_key("test-key")
.build()
.expect("client should build");
let model = client.completion_model("gpt-5.4");
let request = model.completion_request("hello").build();
let mut stream = model.stream(request).await.expect("stream should start");
let mut final_response = None;
while let Some(item) = stream.next().await {
if let StreamedAssistantContent::Final(response) =
item.expect("completed stream should not error")
{
final_response = Some(response);
}
}
assert_eq!(
final_response
.expect("stream should yield a final response")
.finish_reason,
Some(crate::completion::FinishReason::ToolCalls)
);
}
#[test]
fn terminal_record_preserves_an_unknown_incomplete_reason() {
let response = super::StreamingCompletionResponse {
status: Some(ResponseStatus::Incomplete),
incomplete_details: Some(IncompleteDetailsReason {
reason: "MAX_TOOL_CALLS".to_string(),
}),
model: Some("gpt-5.4".to_string()),
message_id: Some("msg_1".to_string()),
..super::StreamingCompletionResponse::new(ResponsesUsage::new())
};
let final_response = crate::streaming::StreamFinal::from(("openai", response));
assert_eq!(
final_response.finish_reason,
Some(crate::completion::FinishReason::Other(
"MAX_TOOL_CALLS".to_string()
))
);
assert_eq!(final_response.message_id.as_deref(), Some("msg_1"));
assert_eq!(final_response.model.as_deref(), Some("gpt-5.4"));
}
#[tokio::test]
async fn done_sentinel_is_ignored_without_debug_parse_noise() {
use std::io::{self, Write};
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedWriter(Arc<Mutex<Vec<u8>>>);
impl Write for SharedWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0
.lock()
.expect("log buffer mutex should not be poisoned")
.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
let mut response = sample_response(ResponseStatus::Completed);
response.usage = Some(ResponsesUsage {
input_tokens: 4,
input_tokens_details: None,
output_tokens: 2,
output_tokens_details: Some(OutputTokensDetails {
reasoning_tokens: 0,
}),
total_tokens: 6,
});
let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
let captured = Arc::new(Mutex::new(Vec::new()));
let subscriber = tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.with_ansi(false)
.without_time()
.with_writer({
let captured = captured.clone();
move || SharedWriter(captured.clone())
})
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
let client = openai::Client::builder()
.http_client(MockStreamingClient {
sse_bytes: bytes::Bytes::from(format!(
"data: {}\n\ndata: [DONE]\n\n",
serde_json::to_string(&json!({
"type": "response.completed",
"sequence_number": 1,
"response": response,
}))
.expect("response event should serialize")
)),
})
.api_key("test-key")
.build()
.expect("client should build");
let model = client.completion_model("gpt-5.4");
let request = model.completion_request("hello").build();
let mut stream = model.stream(request).await.expect("stream should start");
let mut final_usage = None;
while let Some(item) = stream.next().await {
if let StreamedAssistantContent::Final(response) =
item.expect("stream should complete successfully")
{
final_usage = Some(response.usage);
}
}
let usage = final_usage.expect("expected final response");
assert_eq!(usage.input_tokens, 4);
assert_eq!(usage.output_tokens, 2);
assert_eq!(usage.total_tokens, 6);
let logs = String::from_utf8(
captured
.lock()
.expect("log buffer mutex should not be poisoned")
.clone(),
)
.expect("captured logs should be valid UTF-8");
assert!(
!logs.contains("Couldn't deserialize SSE data as StreamingCompletionChunk"),
"expected [DONE] to bypass the parse-failure debug path, logs were: {logs}"
);
}
#[tokio::test]
async fn malformed_frame_surfaces_error_and_stream_still_completes() {
let delta = json!({
"type": "response.output_text.delta",
"content_index": 0,
"delta": "hello",
"item_id": "msg_1",
"logprobs": [],
"output_index": 0,
"sequence_number": 1
});
let completed = json!({
"type": "response.completed",
"sequence_number": 2,
"response": sample_response(ResponseStatus::Completed),
});
let http_client = MockStreamingClient {
sse_bytes: sse_bytes_from_data_lines([
delta.to_string(),
"{not valid json".to_string(),
completed.to_string(),
]),
};
let client = openai::Client::builder()
.http_client(http_client)
.api_key("test-key")
.build()
.expect("client should build");
let model = client.completion_model("gpt-5.4");
let request = model.completion_request("hello").build();
let mut stream = model.stream(request).await.expect("stream should start");
let mut text = String::new();
let mut saw_error = false;
let mut terminal = None;
while let Some(item) = stream.next().await {
match item {
Ok(StreamedAssistantContent::Text(chunk)) => text.push_str(&chunk.text),
Ok(StreamedAssistantContent::Final(final_response)) => {
terminal = Some(final_response)
}
Ok(other) => panic!("unexpected stream item: {other:?}"),
Err(err) => {
assert!(
matches!(err, crate::completion::CompletionError::JsonError(_)),
"expected a JSON parse error item, got {err:?}"
);
saw_error = true;
}
}
}
assert_eq!(text, "hello");
assert!(saw_error, "malformed frame should surface an error item");
assert!(
terminal.is_some(),
"stream should still emit its terminal record"
);
}
}