use std::borrow::Cow;
use std::collections::VecDeque;
use std::fmt;
use std::pin::Pin;
use std::task::{Context, Poll};
use futures_core::Stream;
use serde::{Deserialize, Serialize};
use crate::chat::content::is_valid_json;
pub use crate::chat::transcript::StreamTranscript;
use crate::{
ChatResponse, Citation, ContentBlock, Error, ExtraMap, FinishReason, Image, MediaSource,
OtherContent, ProviderData, ProviderToolCall, ProviderToolResult, Reasoning, Refusal, Result,
StreamErrorKind, Text, ToolCall, Usage, Warning,
};
pub type ChatStream = Pin<Box<dyn Stream<Item = Result<StreamEvent>> + Send + 'static>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum UsageMetadataMode {
Delta,
#[default]
Snapshot,
}
fn is_snapshot(mode: &UsageMetadataMode) -> bool {
*mode == UsageMetadataMode::Snapshot
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum StreamBlockType {
Text,
Image,
Reasoning,
Refusal,
ToolCall,
ProviderToolCall,
ProviderToolResult,
Other,
}
impl StreamBlockType {
pub const ALL: &'static [Self] = &[
Self::Text,
Self::Image,
Self::Reasoning,
Self::Refusal,
Self::ToolCall,
Self::ProviderToolCall,
Self::ProviderToolResult,
Self::Other,
];
fn label(self) -> &'static str {
match self {
Self::Text => "text",
Self::Image => "image",
Self::Reasoning => "reasoning",
Self::Refusal => "refusal",
Self::ToolCall => "tool_call",
Self::ProviderToolCall => "provider_tool_call",
Self::ProviderToolResult => "provider_tool_result",
Self::Other => "other",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
#[non_exhaustive]
pub enum StreamEvent {
#[non_exhaustive]
ResponseStart {
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
model: Option<String>,
},
#[doc(alias = "ToolCallStart")]
#[non_exhaustive]
BlockStart {
index: usize,
block_type: StreamBlockType,
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
type_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
data: Option<ExtraMap>,
#[serde(default, skip_serializing_if = "Option::is_none")]
origin: Option<String>,
#[serde(default, skip_serializing_if = "ProviderData::is_empty")]
provider_data: ProviderData,
},
#[non_exhaustive]
TextDelta {
index: usize,
text: String,
},
#[non_exhaustive]
ReasoningDelta {
index: usize,
text: String,
},
#[non_exhaustive]
RefusalDelta {
index: usize,
text: String,
},
#[non_exhaustive]
ToolCallDelta {
index: usize,
arguments: String,
},
#[non_exhaustive]
Citation {
index: usize,
citation: Citation,
},
#[non_exhaustive]
BlockProviderData {
index: usize,
provider_data: ProviderData,
},
#[non_exhaustive]
ResponseProviderData {
provider_data: ProviderData,
},
#[non_exhaustive]
ProviderDelta {
index: usize,
type_name: String,
#[serde(default, skip_serializing_if = "ExtraMap::is_empty")]
data: ExtraMap,
#[serde(default, skip_serializing_if = "Option::is_none")]
origin: Option<String>,
},
#[non_exhaustive]
BlockStop {
index: usize,
},
#[non_exhaustive]
Warning {
warning: Warning,
},
#[non_exhaustive]
ResponseMetadata {
#[serde(default, skip_serializing_if = "Option::is_none")]
finish_reason: Option<FinishReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
finish_reason_raw: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
usage: Option<Usage>,
#[serde(default, skip_serializing_if = "is_snapshot")]
usage_mode: UsageMetadataMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider: Option<Cow<'static, str>>,
#[serde(default, skip_serializing_if = "ProviderData::is_empty")]
provider_data: ProviderData,
},
ResponseStop,
}
impl StreamEvent {
#[must_use]
pub fn response_start(id: Option<String>, model: Option<String>) -> Self {
Self::ResponseStart { id, model }
}
#[must_use]
pub fn text_delta(index: usize, text: impl Into<String>) -> Self {
Self::TextDelta {
index,
text: text.into(),
}
}
#[must_use]
pub fn reasoning_delta(index: usize, text: impl Into<String>) -> Self {
Self::ReasoningDelta {
index,
text: text.into(),
}
}
#[must_use]
pub fn refusal_delta(index: usize, text: impl Into<String>) -> Self {
Self::RefusalDelta {
index,
text: text.into(),
}
}
#[must_use]
pub fn tool_call_delta(index: usize, arguments: impl Into<String>) -> Self {
Self::ToolCallDelta {
index,
arguments: arguments.into(),
}
}
#[must_use]
pub fn citation(index: usize, citation: Citation) -> Self {
Self::Citation { index, citation }
}
#[must_use]
pub fn block_provider_data(index: usize, provider_data: ProviderData) -> Self {
Self::BlockProviderData {
index,
provider_data,
}
}
#[must_use]
pub fn response_provider_data(provider_data: ProviderData) -> Self {
Self::ResponseProviderData { provider_data }
}
#[must_use]
pub fn block_stop(index: usize) -> Self {
Self::BlockStop { index }
}
#[must_use]
pub fn warning(warning: Warning) -> Self {
Self::Warning { warning }
}
#[must_use]
pub fn metadata() -> Self {
Self::ResponseMetadata {
finish_reason: None,
finish_reason_raw: None,
usage: None,
usage_mode: UsageMetadataMode::Snapshot,
id: None,
model: None,
request_id: None,
provider: None,
provider_data: ProviderData::new(),
}
}
#[must_use]
pub fn block_start(index: usize, block_type: StreamBlockType) -> Self {
Self::BlockStart {
index,
block_type,
id: None,
name: None,
type_name: None,
data: None,
origin: None,
provider_data: ProviderData::new(),
}
}
#[must_use]
pub fn provider_tool_call_start(
index: usize,
id: impl Into<String>,
name: impl Into<String>,
origin: Option<String>,
) -> Self {
Self::BlockStart {
index,
block_type: StreamBlockType::ProviderToolCall,
id: Some(id.into()),
name: Some(name.into()),
type_name: None,
data: None,
origin,
provider_data: ProviderData::new(),
}
}
#[must_use]
pub fn provider_tool_result_start(index: usize, result: ProviderToolResult) -> Self {
let mut data = ExtraMap::new();
data.insert("output".into(), result.output);
if result.is_error {
data.insert("is_error".into(), true.into());
}
Self::BlockStart {
index,
block_type: StreamBlockType::ProviderToolResult,
id: Some(result.call_id),
name: None,
type_name: None,
data: Some(data),
origin: result.origin,
provider_data: result.provider_data,
}
}
#[must_use]
pub fn image_start(index: usize, image: Image) -> Self {
Self::BlockStart {
index,
block_type: StreamBlockType::Image,
id: None,
name: None,
type_name: None,
data: Some(image_data(&image.source, image.media_type.as_deref())),
origin: None,
provider_data: image.provider_data,
}
}
#[must_use]
pub fn other_start(index: usize, other: OtherContent) -> Self {
Self::BlockStart {
index,
block_type: StreamBlockType::Other,
id: None,
name: None,
type_name: Some(other.type_name),
data: Some(other.data),
origin: other.origin,
provider_data: other.provider_data,
}
}
#[must_use]
pub fn provider_delta(
index: usize,
type_name: impl Into<String>,
data: ExtraMap,
origin: Option<String>,
) -> Self {
Self::ProviderDelta {
index,
type_name: type_name.into(),
data,
origin,
}
}
#[must_use]
pub fn tool_call_start(index: usize, id: impl Into<String>, name: impl Into<String>) -> Self {
Self::BlockStart {
index,
block_type: StreamBlockType::ToolCall,
id: Some(id.into()),
name: Some(name.into()),
type_name: None,
data: None,
origin: None,
provider_data: ProviderData::new(),
}
}
#[must_use]
pub fn is_output(&self) -> bool {
match self {
Self::TextDelta { .. }
| Self::ReasoningDelta { .. }
| Self::RefusalDelta { .. }
| Self::ToolCallDelta { .. } => true,
Self::BlockStart { block_type, .. } => {
matches!(
block_type,
StreamBlockType::Image
| StreamBlockType::Refusal
| StreamBlockType::ProviderToolResult
| StreamBlockType::Other
)
}
_ => false,
}
}
fn is_block_event(&self) -> bool {
matches!(
self,
Self::BlockStart { .. }
| Self::TextDelta { .. }
| Self::ReasoningDelta { .. }
| Self::RefusalDelta { .. }
| Self::ToolCallDelta { .. }
| Self::Citation { .. }
| Self::BlockProviderData { .. }
| Self::ProviderDelta { .. }
| Self::BlockStop { .. }
)
}
pub(crate) fn name(&self) -> &'static str {
match self {
Self::ResponseStart { .. } => "response_start",
Self::BlockStart { .. } => "block_start",
Self::TextDelta { .. } => "text_delta",
Self::ReasoningDelta { .. } => "reasoning_delta",
Self::RefusalDelta { .. } => "refusal_delta",
Self::ToolCallDelta { .. } => "tool_call_delta",
Self::Citation { .. } => "citation",
Self::BlockProviderData { .. } => "block_provider_data",
Self::ResponseProviderData { .. } => "response_provider_data",
Self::ProviderDelta { .. } => "provider_delta",
Self::BlockStop { .. } => "block_stop",
Self::Warning { .. } => "warning",
Self::ResponseMetadata { .. } => "response_metadata",
Self::ResponseStop => "response_stop",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[must_use]
pub struct MetadataBuilder {
event: StreamEvent,
}
impl Default for MetadataBuilder {
fn default() -> Self {
Self::new()
}
}
impl MetadataBuilder {
pub fn new() -> Self {
Self {
event: StreamEvent::metadata(),
}
}
pub fn with_finish_reason(self, value: FinishReason) -> Self {
self.with_finish_reason_opt(Some(value))
}
pub fn with_finish_reason_opt(mut self, value: Option<FinishReason>) -> Self {
if let StreamEvent::ResponseMetadata { finish_reason, .. } = &mut self.event {
merge(finish_reason, value);
}
self
}
pub fn with_finish_reason_raw(mut self, value: impl Into<String>) -> Self {
if let StreamEvent::ResponseMetadata {
finish_reason_raw, ..
} = &mut self.event
{
*finish_reason_raw = Some(value.into());
}
self
}
pub fn with_finish_reason_raw_opt(self, value: Option<&str>) -> Self {
match value {
Some(value) => self.with_finish_reason_raw(value),
None => self,
}
}
pub fn with_usage(self, value: Usage) -> Self {
self.with_usage_opt(Some(value))
}
pub fn with_usage_opt(mut self, value: Option<Usage>) -> Self {
if let StreamEvent::ResponseMetadata { usage, .. } = &mut self.event {
merge(usage, value);
}
self
}
pub fn with_usage_mode(mut self, value: UsageMetadataMode) -> Self {
if let StreamEvent::ResponseMetadata { usage_mode, .. } = &mut self.event {
*usage_mode = value;
}
self
}
pub fn with_id(mut self, value: impl Into<String>) -> Self {
if let StreamEvent::ResponseMetadata { id, .. } = &mut self.event {
*id = Some(value.into());
}
self
}
pub fn with_id_opt(self, value: Option<&str>) -> Self {
match value {
Some(value) => self.with_id(value),
None => self,
}
}
pub fn with_model(mut self, value: impl Into<String>) -> Self {
if let StreamEvent::ResponseMetadata { model, .. } = &mut self.event {
*model = Some(value.into());
}
self
}
pub fn with_model_opt(self, value: Option<&str>) -> Self {
match value {
Some(value) => self.with_model(value),
None => self,
}
}
pub fn with_request_id(mut self, value: impl Into<String>) -> Self {
if let StreamEvent::ResponseMetadata { request_id, .. } = &mut self.event {
*request_id = Some(value.into());
}
self
}
pub fn with_request_id_opt(self, value: Option<&str>) -> Self {
match value {
Some(value) => self.with_request_id(value),
None => self,
}
}
pub fn with_provider(self, value: impl Into<Cow<'static, str>>) -> Self {
self.with_provider_opt(Some(value.into()))
}
pub fn with_provider_opt(mut self, value: Option<Cow<'static, str>>) -> Self {
if let StreamEvent::ResponseMetadata { provider, .. } = &mut self.event {
merge(provider, value);
}
self
}
pub fn with_provider_data(mut self, value: ProviderData) -> Self {
if let StreamEvent::ResponseMetadata { provider_data, .. } = &mut self.event {
*provider_data = value;
}
self
}
#[must_use]
pub fn build(self) -> StreamEvent {
self.event
}
}
impl From<MetadataBuilder> for StreamEvent {
fn from(builder: MetadataBuilder) -> Self {
builder.build()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct StreamCompleteness {
pub saw_response_start: bool,
pub saw_response_stop: bool,
pub has_open_blocks: bool,
pub dropped_incomplete_tool_calls: bool,
}
impl StreamCompleteness {
#[must_use]
pub fn is_complete(&self) -> bool {
self.saw_response_start
&& self.saw_response_stop
&& !self.has_open_blocks
&& !self.dropped_incomplete_tool_calls
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct CollectedResponse {
pub response: ChatResponse,
pub completeness: StreamCompleteness,
pub terminal_error: Option<Error>,
}
#[derive(Default)]
pub struct StreamCollector {
blocks: Vec<Option<Slot>>,
skipped_indices: usize,
response: ChatResponse,
saw_response_start: bool,
saw_response_stop: bool,
terminal_error: Option<Error>,
metadata_only: bool,
}
enum Slot {
Open(BlockAccumulator),
Closed {
block_type: StreamBlockType,
block: Finished,
},
}
enum Finished {
Block(ContentBlock),
Empty,
InvalidToolCall(ToolCall),
IncompleteToolCall,
}
struct BlockAccumulator {
kind: BlockKind,
provider_data: ProviderData,
started: bool,
}
impl BlockAccumulator {
fn finish(self, closed: bool) -> Finished {
let provider_data = self.provider_data;
match self.kind {
BlockKind::Text { text, citations }
if text.is_empty() && citations.is_empty() && provider_data.is_empty() =>
{
Finished::Empty
}
BlockKind::Text { text, citations } => Finished::Block(ContentBlock::Text(
Text::new(text)
.with_citations(citations)
.with_provider_data(provider_data),
)),
BlockKind::Image { source, media_type } => {
let mut image = Image::new(source);
image.media_type = media_type;
image.provider_data = provider_data;
Finished::Block(ContentBlock::Image(image))
}
BlockKind::Reasoning(text) if text.is_empty() && provider_data.is_empty() => {
Finished::Empty
}
BlockKind::Reasoning(text) => Finished::Block(ContentBlock::Reasoning(
Reasoning::new(text).with_provider_data(provider_data),
)),
BlockKind::Refusal(text) if text.is_empty() && provider_data.is_empty() => {
Finished::Empty
}
BlockKind::Refusal(text) => Finished::Block(ContentBlock::Refusal(
Refusal::new(text).with_provider_data(provider_data),
)),
BlockKind::ToolCall {
id: Some(id),
name: Some(name),
arguments,
} if closed => {
let arguments = if arguments.trim().is_empty() {
"{}".to_owned()
} else {
arguments
};
let valid = is_valid_json(&arguments);
let call = ToolCall::new(id, name, arguments).with_provider_data(provider_data);
if valid {
Finished::Block(ContentBlock::ToolCall(call))
} else {
Finished::InvalidToolCall(call)
}
}
BlockKind::ToolCall { .. } => Finished::IncompleteToolCall,
BlockKind::ProviderToolCall {
id: Some(id),
name: Some(name),
arguments,
origin,
} if closed => {
let arguments = if arguments.trim().is_empty() {
"{}".to_owned()
} else {
arguments
};
let mut call =
ProviderToolCall::new(id, name, arguments).with_provider_data(provider_data);
call.origin = origin;
Finished::Block(ContentBlock::ProviderToolCall(call))
}
BlockKind::ProviderToolCall { .. } => Finished::IncompleteToolCall,
BlockKind::ProviderToolResult {
call_id,
output,
is_error,
origin,
} => {
let mut result = ProviderToolResult::new(call_id, output)
.with_error(is_error)
.with_provider_data(provider_data);
result.origin = origin;
Finished::Block(ContentBlock::ProviderToolResult(result))
}
BlockKind::Other {
type_name,
data,
origin,
} => {
let mut other =
OtherContent::new(type_name, data).with_provider_data(provider_data);
other.origin = origin;
Finished::Block(ContentBlock::Other(other))
}
}
}
}
enum BlockKind {
Text {
text: String,
citations: Vec<Citation>,
},
Image {
source: MediaSource,
media_type: Option<String>,
},
Reasoning(String),
Refusal(String),
ToolCall {
id: Option<String>,
name: Option<String>,
arguments: String,
},
ProviderToolCall {
id: Option<String>,
name: Option<String>,
arguments: String,
origin: Option<String>,
},
ProviderToolResult {
call_id: String,
output: serde_json::Value,
is_error: bool,
origin: Option<String>,
},
Other {
type_name: String,
data: ExtraMap,
origin: Option<String>,
},
}
impl BlockKind {
fn block_type(&self) -> StreamBlockType {
match self {
Self::Text { .. } => StreamBlockType::Text,
Self::Image { .. } => StreamBlockType::Image,
Self::Reasoning(_) => StreamBlockType::Reasoning,
Self::Refusal(_) => StreamBlockType::Refusal,
Self::ToolCall { .. } => StreamBlockType::ToolCall,
Self::ProviderToolCall { .. } => StreamBlockType::ProviderToolCall,
Self::ProviderToolResult { .. } => StreamBlockType::ProviderToolResult,
Self::Other { .. } => StreamBlockType::Other,
}
}
fn label(&self) -> &'static str {
self.block_type().label()
}
fn text() -> Self {
Self::Text {
text: String::new(),
citations: Vec::new(),
}
}
}
impl StreamCollector {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn metadata_only() -> Self {
Self {
metadata_only: true,
..Self::default()
}
}
pub fn push(&mut self, event: StreamEvent) -> Result<Option<&ContentBlock>> {
if self.saw_response_stop {
return Err(Error::stream_protocol(match event {
StreamEvent::ResponseStop => "duplicate response_stop".to_owned(),
other => format!("{} received after response_stop", other.name()),
}));
}
if self.metadata_only && event.is_block_event() {
return Ok(None);
}
match event {
StreamEvent::ResponseStart { id, model } => {
self.saw_response_start = true;
merge(&mut self.response.id, id);
merge(&mut self.response.model, model);
}
StreamEvent::BlockStart {
index,
block_type,
id,
name,
type_name,
data,
origin,
provider_data,
} => self.start_block(
index,
block_type,
id,
name,
type_name,
data,
origin,
provider_data,
)?,
StreamEvent::TextDelta { index, text } => {
match &mut self.block(index, BlockKind::text)?.kind {
BlockKind::Text { text: buf, .. } => buf.push_str(&text),
other => return Err(mismatch("text_delta", other, index)),
}
}
StreamEvent::ReasoningDelta { index, text } => {
match &mut self
.block(index, || BlockKind::Reasoning(String::new()))?
.kind
{
BlockKind::Reasoning(buf) => buf.push_str(&text),
other => return Err(mismatch("reasoning_delta", other, index)),
}
}
StreamEvent::RefusalDelta { index, text } => {
match &mut self
.block(index, || BlockKind::Refusal(String::new()))?
.kind
{
BlockKind::Refusal(buf) => buf.push_str(&text),
other => return Err(mismatch("refusal_delta", other, index)),
}
}
StreamEvent::ToolCallDelta { index, arguments } => {
let block = self.block(index, || BlockKind::ToolCall {
id: None,
name: None,
arguments: String::new(),
})?;
match &mut block.kind {
BlockKind::ToolCall { arguments: buf, .. }
| BlockKind::ProviderToolCall { arguments: buf, .. } => {
buf.push_str(&arguments)
}
other => return Err(mismatch("tool_call_delta", other, index)),
}
}
StreamEvent::Citation { index, citation } => {
match &mut self.block(index, BlockKind::text)?.kind {
BlockKind::Text { citations, .. } => citations.push(citation),
other => return Err(mismatch("citation", other, index)),
}
}
StreamEvent::BlockProviderData {
index,
provider_data,
} => {
let block = self.open_block(index, "block_provider_data")?;
block.provider_data.merge(provider_data);
}
StreamEvent::ResponseProviderData { provider_data } => {
self.response.provider_data.merge(provider_data);
}
StreamEvent::ProviderDelta { .. } => {}
StreamEvent::BlockStop { index } => return self.stop_block(index),
StreamEvent::Warning { warning } => self.response.warnings.push(warning),
StreamEvent::ResponseMetadata {
finish_reason,
finish_reason_raw,
usage,
usage_mode,
id,
model,
request_id,
provider,
provider_data,
} => {
merge(&mut self.response.finish_reason, finish_reason);
merge(&mut self.response.finish_reason_raw, finish_reason_raw);
merge(&mut self.response.id, id);
merge(&mut self.response.model, model);
merge(&mut self.response.request_id, request_id);
merge(&mut self.response.provider, provider);
self.response.provider_data.merge(provider_data);
if let Some(usage) = usage {
match (usage_mode, &mut self.response.usage) {
(UsageMetadataMode::Delta, Some(existing)) => *existing += usage,
_ => self.response.usage = Some(usage),
}
}
}
StreamEvent::ResponseStop => self.saw_response_stop = true,
}
Ok(None)
}
pub fn push_ref(&mut self, event: &StreamEvent) -> Result<Option<&ContentBlock>> {
if self.metadata_only && event.is_block_event() && !self.saw_response_stop {
return Ok(None);
}
self.push(event.clone())
}
pub fn set_terminal_error(&mut self, error: Error) {
self.terminal_error = Some(error);
}
#[cfg_attr(not(feature = "extract"), allow(dead_code))]
pub(crate) fn response_metadata(&self) -> &ChatResponse {
&self.response
}
#[must_use]
pub fn block_type(&self, index: usize) -> Option<StreamBlockType> {
match self.blocks.get(index)?.as_ref()? {
Slot::Open(block) => Some(block.kind.block_type()),
Slot::Closed { block_type, .. } => Some(*block_type),
}
}
pub fn finish(self) -> Result<ChatResponse> {
let unidentified = self.has_unidentified_tool_call();
let collected = self.build()?;
if let Some(error) = collected.terminal_error {
return Err(error);
}
let c = &collected.completeness;
if !c.is_complete() {
let hint = if unidentified {
"; a tool call has no id or name: start it with \
`StreamEvent::tool_call_start` (`provider_tool_call_start` for a \
provider-executed one), not `block_start`"
} else {
""
};
return Err(Error::stream(
StreamErrorKind::Incomplete,
format!(
"stream incomplete: start={}, stop={}, open_blocks={}, dropped_incomplete_tool_calls={}{hint}",
c.saw_response_start,
c.saw_response_stop,
c.has_open_blocks,
c.dropped_incomplete_tool_calls
),
));
}
Ok(collected.response)
}
pub fn finish_partial(self) -> Result<CollectedResponse> {
self.build()
}
fn block(
&mut self,
index: usize,
default: impl FnOnce() -> BlockKind,
) -> Result<&mut BlockAccumulator> {
self.reserve(index)?;
let slot = self.blocks[index].get_or_insert_with(|| {
Slot::Open(BlockAccumulator {
kind: default(),
provider_data: ProviderData::new(),
started: false,
})
});
match slot {
Slot::Open(block) => Ok(block),
Slot::Closed { block_type, .. } => Err(Error::stream_protocol(format!(
"delta received for closed {} block at index {index}",
block_type.label()
))),
}
}
fn reserve(&mut self, index: usize) -> Result<()> {
if index >= self.blocks.len() {
let gap = index - self.blocks.len();
let skipped = self.skipped_indices.saturating_add(gap);
if skipped > MAX_BLOCK_INDEX_GAP {
return Err(Error::stream_protocol(format!(
"block index {index} leaves {skipped} block indices unused in this \
stream (at most {MAX_BLOCK_INDEX_GAP}); after {} blocks",
self.blocks.len()
)));
}
self.skipped_indices = skipped;
self.blocks.resize_with(index + 1, || None);
}
Ok(())
}
fn open_block(&mut self, index: usize, event: &str) -> Result<&mut BlockAccumulator> {
match self.blocks.get_mut(index).and_then(Option::as_mut) {
Some(Slot::Open(block)) => Ok(block),
Some(Slot::Closed { block_type, .. }) => Err(Error::stream_protocol(format!(
"{event} for closed {} block at index {index}",
block_type.label()
))),
None => Err(Error::stream_protocol(format!(
"{event} for unknown index {index}"
))),
}
}
fn stop_block(&mut self, index: usize) -> Result<Option<&ContentBlock>> {
let slot = self
.blocks
.get_mut(index)
.and_then(Option::take)
.ok_or_else(|| {
Error::stream_protocol(format!("block_stop for unknown index {index}"))
})?;
let closed = match slot {
Slot::Open(block) => Slot::Closed {
block_type: block.kind.block_type(),
block: block.finish(true),
},
Slot::Closed { block_type, block } => {
self.blocks[index] = Some(Slot::Closed { block_type, block });
return Err(Error::stream_protocol(format!(
"duplicate block_stop for {} block at index {index}",
block_type.label()
)));
}
};
let slot = self.blocks[index].insert(closed);
Ok(match slot {
Slot::Closed {
block: Finished::Block(block),
..
} => Some(block),
_ => None,
})
}
#[allow(clippy::too_many_arguments)]
fn start_block(
&mut self,
index: usize,
block_type: StreamBlockType,
id: Option<String>,
name: Option<String>,
type_name: Option<String>,
data: Option<ExtraMap>,
origin: Option<String>,
provider_data: ProviderData,
) -> Result<()> {
let fresh = match block_type {
StreamBlockType::Text => BlockKind::text(),
StreamBlockType::Reasoning => BlockKind::Reasoning(String::new()),
StreamBlockType::Refusal => BlockKind::Refusal(String::new()),
StreamBlockType::ToolCall => BlockKind::ToolCall {
id: id.clone(),
name: name.clone(),
arguments: String::new(),
},
StreamBlockType::Image => {
let (source, media_type) = image_from_data(data.clone())?;
BlockKind::Image { source, media_type }
}
StreamBlockType::ProviderToolCall => BlockKind::ProviderToolCall {
id: id.clone(),
name: name.clone(),
arguments: String::new(),
origin,
},
StreamBlockType::ProviderToolResult => {
let call_id = id.clone().ok_or_else(|| {
Error::stream_protocol(format!(
"provider_tool_result block at index {index} has no call id"
))
})?;
let mut data = data.clone().unwrap_or_default();
BlockKind::ProviderToolResult {
call_id,
output: data.remove("output").unwrap_or_default(),
is_error: data
.remove("is_error")
.and_then(|v| v.as_bool())
.unwrap_or(false),
origin,
}
}
StreamBlockType::Other => BlockKind::Other {
type_name: type_name.unwrap_or_else(|| "other".into()),
data: data.unwrap_or_default(),
origin,
},
};
self.reserve(index)?;
let slot = &mut self.blocks[index];
match slot {
None => {
*slot = Some(Slot::Open(BlockAccumulator {
kind: fresh,
provider_data,
started: true,
}));
Ok(())
}
Some(Slot::Closed { block_type, .. }) => Err(Error::stream_protocol(format!(
"block_start for closed {} block at index {index}",
block_type.label()
))),
Some(Slot::Open(block)) => {
let label = block.kind.label();
if block.started {
return Err(Error::stream_protocol(format!(
"duplicate block_start for {label} block at index {index}"
)));
}
if label != fresh.label() {
return Err(Error::stream_protocol(format!(
"block_start for {} conflicted with existing {label} block at index {index}",
fresh.label()
)));
}
block.started = true;
block.provider_data.merge(provider_data);
if let BlockKind::ToolCall {
id: existing_id,
name: existing_name,
..
} = &mut block.kind
{
merge_strict(existing_id, id, index, "id")?;
merge_strict(existing_name, name, index, "name")?;
}
Ok(())
}
}
}
fn has_unidentified_tool_call(&self) -> bool {
self.blocks.iter().flatten().any(|slot| match slot {
Slot::Open(block) => matches!(
&block.kind,
BlockKind::ToolCall { id, name, .. }
| BlockKind::ProviderToolCall { id, name, .. }
if id.is_none() || name.is_none()
),
Slot::Closed { block, .. } => matches!(block, Finished::IncompleteToolCall),
})
}
fn build(self) -> Result<CollectedResponse> {
let mut content = Vec::with_capacity(self.blocks.len());
let mut has_open_blocks = false;
let mut dropped_incomplete_tool_calls = false;
for slot in self.blocks.into_iter().flatten() {
let finished = match slot {
Slot::Open(block) => {
has_open_blocks = true;
block.finish(false)
}
Slot::Closed { block, .. } => block,
};
match finished {
Finished::Block(block) => content.push(block),
Finished::Empty => {}
Finished::InvalidToolCall(call) => content.push(ContentBlock::ToolCall(call)),
Finished::IncompleteToolCall => {
dropped_incomplete_tool_calls = true;
}
}
}
let mut response = self.response;
response.content = content;
Ok(CollectedResponse {
response,
completeness: StreamCompleteness {
saw_response_start: self.saw_response_start,
saw_response_stop: self.saw_response_stop,
has_open_blocks,
dropped_incomplete_tool_calls,
},
terminal_error: self.terminal_error,
})
}
}
impl fmt::Debug for StreamCollector {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StreamCollector")
.field("blocks", &self.blocks.len())
.field("saw_response_start", &self.saw_response_start)
.field("saw_response_stop", &self.saw_response_stop)
.field(
"terminal_error",
&self.terminal_error.as_ref().map(Error::kind_name),
)
.field("metadata_only", &self.metadata_only)
.finish_non_exhaustive()
}
}
const MAX_BLOCK_INDEX_GAP: usize = 1024;
fn merge<T>(slot: &mut Option<T>, value: Option<T>) {
if value.is_some() {
*slot = value;
}
}
fn merge_strict(
existing: &mut Option<String>,
incoming: Option<String>,
index: usize,
field: &str,
) -> Result<()> {
match (existing.as_ref(), incoming) {
(Some(current), Some(new)) if current != &new => Err(Error::stream_protocol(format!(
"conflicting {field} for tool call block at index {index}"
))),
(None, Some(new)) => {
*existing = Some(new);
Ok(())
}
_ => Ok(()),
}
}
fn mismatch(event: &str, kind: &BlockKind, index: usize) -> Error {
Error::stream_protocol(format!(
"{event} received for {} block at index {index}",
kind.label()
))
}
fn image_from_data(data: Option<ExtraMap>) -> Result<(MediaSource, Option<String>)> {
let mut data = data.ok_or_else(|| Error::stream_protocol("image block missing source"))?;
let source = data
.remove("source")
.ok_or_else(|| Error::stream_protocol("image block missing source"))?;
let source = serde_json::from_value(source)
.map_err(|e| Error::stream_decode(format!("image block source was invalid: {e}")))?;
let media_type = match data.remove("media_type") {
Some(serde_json::Value::String(media_type)) => Some(media_type),
_ => None,
};
Ok((source, media_type))
}
fn image_data(source: &MediaSource, media_type: Option<&str>) -> ExtraMap {
let mut data = ExtraMap::new();
data.insert(
"source".into(),
serde_json::to_value(source).expect("media source serializes"),
);
if let Some(media_type) = media_type {
data.insert(
"media_type".into(),
serde_json::Value::String(media_type.to_owned()),
);
}
data
}
pub struct SingleResponseStream {
events: std::vec::IntoIter<StreamEvent>,
}
impl SingleResponseStream {
#[must_use]
pub fn new(response: ChatResponse) -> Self {
Self {
events: response
.into_stream_events()
.collect::<Vec<_>>()
.into_iter(),
}
}
}
impl fmt::Debug for SingleResponseStream {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SingleResponseStream")
.field("remaining_events", &self.events.len())
.finish()
}
}
impl From<ChatResponse> for SingleResponseStream {
fn from(response: ChatResponse) -> Self {
Self::new(response)
}
}
impl Stream for SingleResponseStream {
type Item = Result<StreamEvent>;
fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
Poll::Ready(self.events.next().map(Ok))
}
}
impl ChatResponse {
#[must_use]
pub fn into_stream(self) -> SingleResponseStream {
SingleResponseStream::new(self)
}
pub fn into_stream_events(self) -> impl Iterator<Item = StreamEvent> {
let ChatResponse {
content,
finish_reason,
finish_reason_raw,
usage,
model,
id,
request_id,
provider,
warnings,
provider_data,
finish_reported: _,
} = self;
let mut events = Vec::with_capacity(content.len() * 3 + warnings.len() + 3);
events.push(StreamEvent::ResponseStart {
id: id.clone(),
model: model.clone(),
});
events.extend(
warnings
.into_iter()
.map(|warning| StreamEvent::Warning { warning }),
);
for (index, block) in content.into_iter().enumerate() {
block_events(index, block, &mut events);
}
events.push(StreamEvent::ResponseMetadata {
finish_reason,
finish_reason_raw,
usage,
usage_mode: UsageMetadataMode::Snapshot,
id,
model,
request_id,
provider,
provider_data,
});
events.push(StreamEvent::ResponseStop);
events.into_iter()
}
}
fn block_events(index: usize, block: ContentBlock, events: &mut Vec<StreamEvent>) {
let mut start = |block_type, id, name, type_name, data, origin, provider_data| {
events.push(StreamEvent::BlockStart {
index,
block_type,
id,
name,
type_name,
data,
origin,
provider_data,
});
};
match block {
ContentBlock::Text(text) => {
start(
StreamBlockType::Text,
None,
None,
None,
None,
None,
text.provider_data,
);
if !text.text.is_empty() {
events.push(StreamEvent::TextDelta {
index,
text: text.text,
});
}
events.extend(
text.citations
.into_iter()
.map(|citation| StreamEvent::Citation { index, citation }),
);
}
ContentBlock::Image(image) => events.push(StreamEvent::image_start(index, image)),
ContentBlock::Reasoning(reasoning) => {
start(
StreamBlockType::Reasoning,
None,
None,
None,
None,
None,
reasoning.provider_data,
);
if !reasoning.text.is_empty() {
events.push(StreamEvent::ReasoningDelta {
index,
text: reasoning.text,
});
}
}
ContentBlock::Refusal(refusal) => {
start(
StreamBlockType::Refusal,
None,
None,
None,
None,
None,
refusal.provider_data,
);
if !refusal.text.is_empty() {
events.push(StreamEvent::RefusalDelta {
index,
text: refusal.text,
});
}
}
ContentBlock::ToolCall(call) => {
start(
StreamBlockType::ToolCall,
Some(call.id),
Some(call.name),
None,
None,
None,
call.provider_data,
);
events.push(StreamEvent::ToolCallDelta {
index,
arguments: call.arguments,
});
}
ContentBlock::ProviderToolCall(call) => {
start(
StreamBlockType::ProviderToolCall,
Some(call.id),
Some(call.name),
None,
None,
call.origin,
call.provider_data,
);
events.push(StreamEvent::ToolCallDelta {
index,
arguments: call.arguments,
});
}
ContentBlock::ProviderToolResult(result) => {
events.push(StreamEvent::provider_tool_result_start(index, result));
}
ContentBlock::Other(other) => events.push(StreamEvent::other_start(index, other)),
}
events.push(StreamEvent::BlockStop { index });
}
pub trait ChatStreamExt: Stream<Item = Result<StreamEvent>> + Send {
fn collect_response(self) -> impl std::future::Future<Output = Result<ChatResponse>> + Send;
fn next_event(
&mut self,
) -> impl std::future::Future<Output = Option<Result<StreamEvent>>> + Send
where
Self: Unpin,
{
std::future::poll_fn(move |cx| Pin::new(&mut *self).poll_next(cx))
}
fn with_collector(self) -> WithCollector<Self>
where
Self: Sized,
{
WithCollector {
inner: Box::pin(self),
collector: StreamCollector::new(),
done: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct CollectedEvent {
pub event: StreamEvent,
pub completed: Option<ContentBlock>,
}
impl CollectedEvent {
#[must_use]
pub fn new(event: StreamEvent, completed: Option<ContentBlock>) -> Self {
Self { event, completed }
}
#[must_use]
pub fn into_parts(self) -> (StreamEvent, Option<ContentBlock>) {
(self.event, self.completed)
}
}
impl From<CollectedEvent> for (StreamEvent, Option<ContentBlock>) {
fn from(item: CollectedEvent) -> Self {
item.into_parts()
}
}
pub struct WithCollector<S> {
inner: Pin<Box<S>>,
collector: StreamCollector,
done: bool,
}
impl<S> WithCollector<S>
where
S: Stream<Item = Result<StreamEvent>>,
{
pub async fn next_event(&mut self) -> Option<Result<CollectedEvent>> {
std::future::poll_fn(|cx| Pin::new(&mut *self).poll_next(cx)).await
}
}
impl<S> WithCollector<S> {
#[must_use]
pub fn collector(&self) -> &StreamCollector {
&self.collector
}
pub fn finish(self) -> Result<ChatResponse> {
self.collector.finish()
}
pub fn finish_partial(self) -> Result<CollectedResponse> {
self.collector.finish_partial()
}
}
impl<S> fmt::Debug for WithCollector<S> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WithCollector")
.field("collector", &self.collector)
.field("done", &self.done)
.finish_non_exhaustive()
}
}
impl<S> Stream for WithCollector<S>
where
S: Stream<Item = Result<StreamEvent>>,
{
type Item = Result<CollectedEvent>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = &mut *self;
if this.done {
return Poll::Ready(None);
}
match this.inner.as_mut().poll_next(cx) {
Poll::Ready(Some(Ok(event))) => match this.collector.push_ref(&event) {
Ok(completed) => {
let completed = completed.cloned();
Poll::Ready(Some(Ok(CollectedEvent { event, completed })))
}
Err(error) => {
this.done = true;
this.collector.set_terminal_error(error.clone());
Poll::Ready(Some(Err(error)))
}
},
Poll::Ready(Some(Err(error))) => {
this.done = true;
this.collector.set_terminal_error(error.clone());
Poll::Ready(Some(Err(error)))
}
Poll::Ready(None) => {
this.done = true;
Poll::Ready(None)
}
Poll::Pending => Poll::Pending,
}
}
}
impl<S: Stream<Item = Result<StreamEvent>> + Send> ChatStreamExt for S {
async fn collect_response(self) -> Result<ChatResponse> {
use futures_util::StreamExt;
let mut collector = StreamCollector::new();
let mut stream = std::pin::pin!(self);
while let Some(event) = stream.next().await {
collector.push(event?)?;
}
collector.finish()
}
}
#[doc(alias = "CommittedStream")]
pub struct Prefixed<S> {
buffer: VecDeque<StreamEvent>,
inner: Option<S>,
}
impl<S> Prefixed<S> {
#[must_use]
pub fn new(buffer: impl IntoIterator<Item = StreamEvent>, inner: Option<S>) -> Self {
Self {
buffer: buffer.into_iter().collect(),
inner,
}
}
#[must_use]
pub fn into_parts(self) -> (Vec<StreamEvent>, Option<S>) {
(self.buffer.into(), self.inner)
}
}
impl<S> fmt::Debug for Prefixed<S> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Prefixed")
.field("buffered_events", &self.buffer.len())
.field("ended", &self.inner.is_none())
.finish()
}
}
impl<S: Stream<Item = Result<StreamEvent>> + Unpin> Stream for Prefixed<S> {
type Item = Result<StreamEvent>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
if let Some(event) = self.buffer.pop_front() {
return Poll::Ready(Some(Ok(event)));
}
match self.inner.as_mut() {
Some(inner) => Pin::new(inner).poll_next(cx),
None => Poll::Ready(None),
}
}
}
pub async fn until_committed<S>(mut stream: S) -> Result<Prefixed<S>>
where
S: Stream<Item = Result<StreamEvent>> + Unpin,
{
use futures_util::StreamExt;
let mut buffered = Vec::new();
loop {
match stream.next().await {
Some(Ok(event)) => {
let committed = event.is_output();
buffered.push(event);
if committed {
return Ok(Prefixed::new(buffered, Some(stream)));
}
}
Some(Err(error)) => return Err(error),
None => return Ok(Prefixed::new(buffered, None)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{CitationSource, RequestField, WarningKind};
use futures_util::StreamExt;
use serde_json::json;
#[tokio::test]
async fn stream_types_debug_without_content() {
let stream: ChatStream =
Box::pin(ChatResponse::new(vec![ContentBlock::text("secret answer")]).into_stream());
let single = SingleResponseStream::from(ChatResponse::new(vec![ContentBlock::text(
"secret answer",
)]));
assert!(format!("{single:?}").starts_with("SingleResponseStream { remaining_events: "));
let mut collecting = stream.with_collector();
while collecting.next_event().await.is_some() {}
let debug = format!("{collecting:?}");
assert!(
debug.starts_with("WithCollector { collector: StreamCollector { blocks: 1"),
"{debug}"
);
assert!(!debug.contains("secret"), "{debug}");
let prefixed = Prefixed::<ChatStream>::new([StreamEvent::text_delta(0, "secret")], None);
assert_eq!(
format!("{prefixed:?}"),
"Prefixed { buffered_events: 1, ended: true }"
);
}
#[test]
fn fieldless_enums_are_hashable() {
use std::collections::HashSet;
use crate::{CapabilitySupport, ChatCapability, StreamErrorKind};
let capabilities: HashSet<(ChatCapability, CapabilitySupport)> =
[(ChatCapability::ToolCalls, CapabilitySupport::Supported)].into();
assert!(capabilities.contains(&(ChatCapability::ToolCalls, CapabilitySupport::Supported)));
let _: HashSet<StreamErrorKind> = HashSet::new();
let _: HashSet<UsageMetadataMode> = HashSet::new();
#[cfg(feature = "extract")]
{
let _: HashSet<crate::extract::RefusalSource> = HashSet::new();
let _: HashSet<crate::extract::ExtractionMode> = HashSet::new();
}
}
fn sample_response() -> ChatResponse {
let mut call = ToolCall::new("c1", "lookup", r#"{"q":1}"#);
call.provider_data
.insert_raw("google-ai", "thought_signature", json!("sig"));
ChatResponse::new(vec![
ContentBlock::Reasoning(Reasoning::new("think").with_provider_data(
ProviderData::new().with_raw("anthropic", "signature", json!("s")),
)),
ContentBlock::Text(Text::new("hello").with_citations(vec![
Citation::new(CitationSource::url("https://x", Some("X".into()))).with_span(0..5),
])),
ContentBlock::Refusal(Refusal::new("I can't help with that.")),
ContentBlock::ToolCall(call),
ContentBlock::Image(Image::url("https://x/y.png")),
ContentBlock::Other(
OtherContent::new("citation", ExtraMap::new())
.with_origin("anthropic")
.with_provider_data(ProviderData::new().with_raw(
"anthropic",
"signature",
json!("other-sig"),
)),
),
])
.finish_reason(FinishReason::ToolCalls)
.finish_reason_raw("tool_use")
.usage(Usage::new().input_tokens(1).output_tokens(2))
.id("r1")
.model("m")
.request_id("req")
.provider("demo")
.warning(Warning::unsupported(RequestField::Seed, "ignored"))
}
#[tokio::test]
async fn response_round_trips_through_stream() {
let response = sample_response();
let collected = response
.clone()
.into_stream()
.collect_response()
.await
.unwrap();
assert_eq!(collected, response);
}
#[tokio::test]
async fn refusals_stream_apart_from_text() {
let response = ChatResponse::new(vec![
ContentBlock::text("Sure."),
ContentBlock::Refusal(Refusal::new("I can't help with that.")),
]);
let events: Vec<StreamEvent> = response.clone().into_stream_events().collect();
assert!(events.iter().any(|event| matches!(
event,
StreamEvent::BlockStart {
index: 1,
block_type: StreamBlockType::Refusal,
..
}
)));
assert!(events.contains(&StreamEvent::refusal_delta(1, "I can't help with that.")));
assert!(!events.iter().any(|event| matches!(
event,
StreamEvent::TextDelta { text, .. } if text.contains("can't")
)));
let collected = response
.clone()
.into_stream()
.collect_response()
.await
.unwrap();
assert_eq!(collected, response);
assert_eq!(collected.first_refusal(), Some("I can't help with that."));
assert_eq!(collected.text().as_deref(), Some("Sure."));
let mut collector = StreamCollector::new();
collector
.push(StreamEvent::refusal_delta(0, "No."))
.unwrap();
assert_eq!(collector.block_type(0), Some(StreamBlockType::Refusal));
let error = collector.push(StreamEvent::text_delta(0, "x")).unwrap_err();
assert!(error.to_string().contains("refusal block"), "{error}");
}
#[test]
fn all_block_types_listed() {
fn _exhaustive(block_type: StreamBlockType) {
match block_type {
StreamBlockType::Text
| StreamBlockType::Image
| StreamBlockType::Reasoning
| StreamBlockType::Refusal
| StreamBlockType::ToolCall
| StreamBlockType::ProviderToolCall
| StreamBlockType::ProviderToolResult
| StreamBlockType::Other => {}
}
}
let unique: std::collections::HashSet<_> = StreamBlockType::ALL.iter().collect();
assert_eq!(unique.len(), StreamBlockType::ALL.len());
assert_eq!(StreamBlockType::ALL.len(), 8);
}
#[test]
fn refusals_are_output() {
assert!(StreamEvent::refusal_delta(0, "No.").is_output());
assert!(StreamEvent::block_start(0, StreamBlockType::Refusal).is_output());
assert!(StreamEvent::refusal_delta(0, "No.").is_block_event());
}
#[test]
fn events_serialize_round_trip() {
let events: Vec<StreamEvent> = sample_response().into_stream_events().collect();
let value = serde_json::to_value(&events).unwrap();
let back: Vec<StreamEvent> = serde_json::from_value(value).unwrap();
assert_eq!(back, events);
}
#[test]
fn block_provider_data_merges() {
let mut c = StreamCollector::new();
c.push(StreamEvent::ResponseStart {
id: None,
model: None,
})
.unwrap();
c.push(StreamEvent::block_start(0, StreamBlockType::Reasoning))
.unwrap();
c.push(StreamEvent::ReasoningDelta {
index: 0,
text: "a".into(),
})
.unwrap();
c.push(StreamEvent::BlockProviderData {
index: 0,
provider_data: ProviderData::new().with_raw("anthropic", "signature", json!("s")),
})
.unwrap();
c.push(StreamEvent::BlockStop { index: 0 }).unwrap();
c.push(StreamEvent::Warning {
warning: Warning::new(WarningKind::Dropped, crate::Subject::Response, "x"),
})
.unwrap();
c.push(StreamEvent::ResponseStop).unwrap();
let response = c.finish().unwrap();
let reasoning = response.content[0].as_reasoning().unwrap();
assert_eq!(
reasoning.provider_data.get_raw("anthropic", "signature"),
Some(&json!("s"))
);
assert_eq!(response.warnings.len(), 1);
}
#[tokio::test]
async fn other_block_keeps_its_provider_data() {
let other = OtherContent::new("compaction", ExtraMap::new())
.with_origin("anthropic")
.with_provider_data(ProviderData::new().with_raw("anthropic", "start", json!(1)));
let stream = futures_util::stream::iter(vec![
Ok(StreamEvent::response_start(None, None)),
Ok(StreamEvent::other_start(0, other)),
Ok(StreamEvent::block_provider_data(
0,
ProviderData::new().with_raw("anthropic", "signature", json!("other-sig")),
)),
Ok(StreamEvent::block_stop(0)),
Ok(StreamEvent::ResponseStop),
]);
let response = stream.collect_response().await.unwrap();
assert_eq!(
response.content[0].provider_data(),
&ProviderData::new()
.with_raw("anthropic", "start", json!(1))
.with_raw("anthropic", "signature", json!("other-sig"))
);
}
#[test]
fn response_provider_data_merges_into_the_response() {
let event = StreamEvent::response_provider_data(ProviderData::new().with_raw(
"openai-compat:lumen",
"search_results",
json!([{"url": "https://a"}]),
));
let value = serde_json::to_value(&event).unwrap();
assert_eq!(
value,
json!({"event": "response_provider_data", "provider_data": {"openai-compat:lumen": {"search_results": [{"url": "https://a"}]}}})
);
assert_eq!(serde_json::from_value::<StreamEvent>(value).unwrap(), event);
assert!(!event.is_output());
let mut c = StreamCollector::new();
for item in sample_response().into_stream_events() {
if matches!(item, StreamEvent::BlockStart { index: 0, .. }) {
c.push(event.clone()).unwrap();
}
c.push(item).unwrap();
}
let response = c.finish().unwrap();
assert_eq!(
response
.provider_data
.get_raw("openai-compat:lumen", "search_results"),
Some(&json!([{"url": "https://a"}]))
);
let mut expected = sample_response();
expected.provider_data.insert_raw(
"openai-compat:lumen",
"search_results",
json!([{"url": "https://a"}]),
);
assert_eq!(response, expected);
let mut c = StreamCollector::new();
c.push(StreamEvent::ResponseStop).unwrap();
let err = c
.push(StreamEvent::response_provider_data(ProviderData::new()))
.unwrap_err();
assert!(err.to_string().contains("response_provider_data"), "{err}");
}
#[test]
fn provider_deltas_are_observed_not_collected() {
let mut data = ExtraMap::new();
data.insert("query".into(), json!("rust"));
let delta = StreamEvent::provider_delta(
3,
"web_search_call.searching",
data,
Some("openai".into()),
);
let value = serde_json::to_value(&delta).unwrap();
assert_eq!(
value,
json!({"event": "provider_delta", "index": 3, "type_name": "web_search_call.searching", "data": {"query": "rust"}, "origin": "openai"})
);
assert_eq!(serde_json::from_value::<StreamEvent>(value).unwrap(), delta);
assert!(!delta.is_output());
let mut c = StreamCollector::new();
for event in sample_response().into_stream_events() {
if matches!(event, StreamEvent::ResponseStop) {
c.push(delta.clone()).unwrap();
}
c.push(event).unwrap();
}
assert_eq!(c.finish().unwrap(), sample_response());
}
#[test]
fn push_returns_blocks_as_they_complete() {
let mut c = StreamCollector::new();
let mut completed = Vec::new();
for event in sample_response().into_stream_events() {
if let Some(block) = c.push(event).unwrap() {
completed.push(block.clone());
}
}
assert_eq!(completed, sample_response().content);
let mut c = StreamCollector::new();
c.push(StreamEvent::block_start(0, StreamBlockType::Text))
.unwrap();
assert!(
c.push(StreamEvent::BlockStop { index: 0 })
.unwrap()
.is_none()
);
c.push(StreamEvent::block_start(1, StreamBlockType::ToolCall))
.unwrap();
assert!(
c.push(StreamEvent::BlockStop { index: 1 })
.unwrap()
.is_none()
);
c.push(StreamEvent::tool_call_start(2, "c", "f")).unwrap();
c.push(StreamEvent::ToolCallDelta {
index: 2,
arguments: "{bad".into(),
})
.unwrap();
assert!(
c.push(StreamEvent::BlockStop { index: 2 })
.unwrap()
.is_none()
);
let partial = c.finish_partial().unwrap();
let calls: Vec<_> = partial.response.tool_calls().collect();
assert_eq!(calls, [&ToolCall::new("c", "f", "{bad")]);
assert!(!calls[0].has_valid_arguments());
assert!(partial.completeness.dropped_incomplete_tool_calls);
}
#[test]
fn a_tool_call_opened_without_an_id_names_its_constructor() {
let mut c = StreamCollector::new();
c.push(StreamEvent::response_start(Some("r".into()), None))
.unwrap();
c.push(StreamEvent::block_start(0, StreamBlockType::ToolCall))
.unwrap();
c.push(StreamEvent::BlockStop { index: 0 }).unwrap();
c.push(StreamEvent::ResponseStop).unwrap();
let err = c.finish().unwrap_err();
assert!(
err.message().contains("`StreamEvent::tool_call_start`"),
"{err}"
);
let mut c = StreamCollector::new();
c.push(StreamEvent::response_start(Some("r".into()), None))
.unwrap();
c.push(StreamEvent::tool_call_start(0, "c", "f")).unwrap();
let err = c.finish().unwrap_err();
assert!(!err.message().contains("tool_call_start"), "{err}");
}
#[test]
fn unfinished_provider_tool_calls_are_dropped_from_partial_responses() {
let mut c = StreamCollector::new();
c.push(StreamEvent::provider_tool_call_start(
0,
"srv_1",
"web_search",
Some("anthropic".into()),
))
.unwrap();
c.push(StreamEvent::ToolCallDelta {
index: 0,
arguments: r#"{"query":"ru"#.into(),
})
.unwrap();
let partial = c.finish_partial().unwrap();
assert!(partial.response.content.is_empty());
assert!(partial.completeness.dropped_incomplete_tool_calls);
}
#[test]
fn open_tool_calls_are_dropped_from_partial_responses() {
let mut c = StreamCollector::new();
c.push(StreamEvent::tool_call_start(0, "c1", "get_weather"))
.unwrap();
c.push(StreamEvent::tool_call_delta(0, r#"{"city":"Pa"#))
.unwrap();
let partial = c.finish_partial().unwrap();
assert_eq!(partial.response.tool_calls().count(), 0);
assert!(partial.completeness.dropped_incomplete_tool_calls);
}
#[test]
fn open_tool_call_without_id_or_name_has_a_block_type() {
let mut c = StreamCollector::new();
c.push(StreamEvent::tool_call_delta(0, "{")).unwrap();
assert_eq!(c.block_type(0), Some(StreamBlockType::ToolCall));
}
#[test]
fn closed_tool_calls_complete_and_are_collected() {
let mut c = StreamCollector::new();
c.push(StreamEvent::tool_call_start(0, "c1", "get_weather"))
.unwrap();
c.push(StreamEvent::tool_call_delta(0, r#"{"city":"Paris"}"#))
.unwrap();
let first = c.push(StreamEvent::block_stop(0)).unwrap().cloned();
c.push(StreamEvent::tool_call_start(1, "c2", "now"))
.unwrap();
let second = c.push(StreamEvent::block_stop(1)).unwrap().cloned();
let expected = [
ToolCall::new("c1", "get_weather", r#"{"city":"Paris"}"#),
ToolCall::new("c2", "now", "{}"),
];
assert_eq!(
[first, second],
expected
.clone()
.map(|call| Some(ContentBlock::ToolCall(call)))
);
let partial = c.finish_partial().unwrap();
assert!(partial.response.tool_calls().eq(&expected));
}
#[test]
fn invalid_arguments_complete_as_nothing_but_are_collected() {
let mut c = StreamCollector::new();
c.push(StreamEvent::tool_call_start(0, "c1", "get_weather"))
.unwrap();
c.push(StreamEvent::tool_call_delta(0, r#"{"city":"#))
.unwrap();
assert!(c.push(StreamEvent::block_stop(0)).unwrap().is_none());
let partial = c.finish_partial().unwrap();
let call = partial.response.first_tool_call().unwrap();
assert_eq!(
(call.id.as_str(), call.arguments.as_str()),
("c1", r#"{"city":"#)
);
assert!(!call.has_valid_arguments());
}
#[test]
fn provider_tool_calls_never_appear_as_tool_calls() {
let mut c = StreamCollector::new();
c.push(StreamEvent::provider_tool_call_start(
0,
"srv_1",
"web_search",
Some("anthropic".into()),
))
.unwrap();
c.push(StreamEvent::tool_call_delta(0, r#"{"query":"rust"}"#))
.unwrap();
c.push(StreamEvent::block_stop(0)).unwrap();
c.push(StreamEvent::tool_call_start(1, "c1", "get_weather"))
.unwrap();
c.push(StreamEvent::block_stop(1)).unwrap();
let partial = c.finish_partial().unwrap();
let ids: Vec<_> = partial
.response
.tool_calls()
.map(|call| call.id.as_str())
.collect();
assert_eq!(ids, ["c1"]);
assert_eq!(partial.response.content.len(), 2);
}
#[test]
fn block_type_reports_open_and_closed_blocks() {
let mut c = StreamCollector::new();
assert_eq!(c.block_type(0), None);
c.push(StreamEvent::text_delta(0, "Hi")).unwrap();
c.push(StreamEvent::provider_tool_call_start(
1,
"srv_1",
"web_search",
None,
))
.unwrap();
c.push(StreamEvent::tool_call_start(3, "c1", "f")).unwrap();
assert_eq!(c.block_type(0), Some(StreamBlockType::Text));
assert_eq!(c.block_type(1), Some(StreamBlockType::ProviderToolCall));
assert_eq!(c.block_type(2), None, "gap in the indexes");
assert_eq!(c.block_type(3), Some(StreamBlockType::ToolCall));
assert_eq!(c.block_type(4), None);
for index in [0, 1, 3] {
c.push(StreamEvent::block_stop(index)).unwrap();
}
assert_eq!(c.block_type(0), Some(StreamBlockType::Text));
assert_eq!(c.block_type(1), Some(StreamBlockType::ProviderToolCall));
assert_eq!(c.block_type(2), None);
assert_eq!(c.block_type(3), Some(StreamBlockType::ToolCall));
c.push(StreamEvent::block_start(5, StreamBlockType::Text))
.unwrap();
c.push(StreamEvent::block_stop(5)).unwrap();
assert_eq!(c.block_type(5), Some(StreamBlockType::Text));
}
#[test]
fn stopped_blocks_reject_further_events() {
let mut c = StreamCollector::new();
c.push(StreamEvent::block_start(0, StreamBlockType::Text))
.unwrap();
c.push(StreamEvent::BlockStop { index: 0 }).unwrap();
for event in [
StreamEvent::TextDelta {
index: 0,
text: "late".into(),
},
StreamEvent::BlockProviderData {
index: 0,
provider_data: ProviderData::new().with_raw("x", "k", json!(1)),
},
StreamEvent::block_start(0, StreamBlockType::Text),
StreamEvent::BlockStop { index: 0 },
] {
let err = c.push(event).unwrap_err();
assert!(err.to_string().contains("text block"), "{err}");
}
}
#[tokio::test]
async fn with_collector_pairs_stops_with_blocks() {
let mut stream = sample_response().into_stream().with_collector();
let mut completed = Vec::new();
while let Some(item) = stream.next_event().await {
let (event, block) = item.unwrap().into_parts();
assert_eq!(
block.is_some(),
matches!(event, StreamEvent::BlockStop { .. })
);
completed.extend(block);
}
assert_eq!(completed, sample_response().content);
assert_eq!(stream.finish().unwrap(), sample_response());
}
#[tokio::test]
async fn with_collector_records_stream_errors() {
let events = futures_util::stream::iter(vec![
Ok(StreamEvent::ResponseStart {
id: None,
model: None,
}),
Ok(StreamEvent::TextDelta {
index: 0,
text: "partial".into(),
}),
Err(Error::overloaded("busy")),
]);
let after = futures_util::stream::iter([Ok(StreamEvent::text_delta(0, "after"))]);
let mut stream = events.chain(after).with_collector();
let items: Vec<_> = stream.by_ref().collect().await;
assert_eq!(items.len(), 3);
assert!(items[2].is_err());
let partial = stream.finish_partial().unwrap();
assert_eq!(partial.response.text().as_deref(), Some("partial"));
assert!(matches!(
partial.terminal_error,
Some(Error::Overloaded { .. })
));
}
#[test]
fn incomplete_stream_is_retryable() {
let mut c = StreamCollector::new();
c.push(StreamEvent::ResponseStart {
id: None,
model: None,
})
.unwrap();
c.push(StreamEvent::TextDelta {
index: 0,
text: "partial".into(),
})
.unwrap();
let err = c.finish().unwrap_err();
assert!(matches!(
err,
Error::Stream {
kind: StreamErrorKind::Incomplete,
..
}
));
assert!(err.is_retryable());
}
#[test]
fn protocol_violations_are_rejected() {
let mut c = StreamCollector::new();
c.push(StreamEvent::ResponseStop).unwrap();
assert!(c.push(StreamEvent::ResponseStop).is_err());
let mut c = StreamCollector::new();
c.push(StreamEvent::block_start(0, StreamBlockType::Text))
.unwrap();
assert!(
c.push(StreamEvent::ToolCallDelta {
index: 0,
arguments: "{}".into()
})
.is_err()
);
let mut c = StreamCollector::new();
c.push(StreamEvent::tool_call_start(0, "a", "f")).unwrap();
assert!(c.push(StreamEvent::tool_call_start(0, "b", "f")).is_err());
}
#[test]
fn invalid_tool_arguments_are_kept_like_unary() {
let events = || {
vec![
StreamEvent::ResponseStart {
id: None,
model: None,
},
StreamEvent::tool_call_start(0, "c", "f"),
StreamEvent::ToolCallDelta {
index: 0,
arguments: "{bad".into(),
},
StreamEvent::BlockStop { index: 0 },
StreamEvent::ResponseStop,
]
};
let mut strict = StreamCollector::new();
for e in events() {
strict.push(e).unwrap();
}
let response = strict.finish().unwrap();
let call = response.first_tool_call().unwrap();
assert_eq!(call.arguments, "{bad");
assert!(!call.has_valid_arguments());
let mut partial = StreamCollector::new();
for e in events() {
partial.push(e).unwrap();
}
let collected = partial.finish_partial().unwrap();
assert!(!collected.completeness.dropped_incomplete_tool_calls);
assert_eq!(collected.response.content.len(), 1);
}
#[test]
fn absurd_block_indices_are_protocol_errors() {
for index in [usize::MAX, usize::MAX - 1, 10_000_000_000, 50_000_000] {
let mut c = StreamCollector::new();
let err = c.push(StreamEvent::text_delta(index, "x")).unwrap_err();
assert!(
matches!(
err,
Error::Stream {
kind: StreamErrorKind::Protocol,
..
}
),
"{err:?}"
);
let mut c = StreamCollector::new();
assert!(
c.push(StreamEvent::block_start(index, StreamBlockType::Text))
.is_err()
);
}
let mut c = StreamCollector::new();
c.push(StreamEvent::text_delta(MAX_BLOCK_INDEX_GAP, "x"))
.unwrap();
assert_eq!(
c.block_type(MAX_BLOCK_INDEX_GAP),
Some(StreamBlockType::Text)
);
}
#[test]
fn stepped_block_indices_are_bounded_in_total() {
let mut c = StreamCollector::new();
let mut failed = None;
for step in 1..=4 {
let index = step * (MAX_BLOCK_INDEX_GAP / 2 + 1);
if let Err(err) = c.push(StreamEvent::text_delta(index, "x")) {
failed = Some((step, err));
break;
}
}
let (step, err) = failed.expect("stepped indices are refused");
assert_eq!(step, 2, "the second gap passes the stream's total");
assert!(
matches!(
err,
Error::Stream {
kind: StreamErrorKind::Protocol,
..
}
),
"{err:?}"
);
assert!(
c.blocks.len() <= MAX_BLOCK_INDEX_GAP + 2,
"{}",
c.blocks.len()
);
let mut c = StreamCollector::new();
for index in 0..4 * MAX_BLOCK_INDEX_GAP {
c.push(StreamEvent::text_delta(index, "x")).unwrap();
}
}
#[test]
fn delta_usage_accumulates() {
let mut c = StreamCollector::new();
for usage in [Usage::new().output_tokens(2), Usage::new().output_tokens(3)] {
c.push(StreamEvent::ResponseMetadata {
usage: Some(usage),
usage_mode: UsageMetadataMode::Delta,
finish_reason: None,
finish_reason_raw: None,
id: None,
model: None,
request_id: None,
provider: None,
provider_data: ProviderData::new(),
})
.unwrap();
}
let collected = c.finish_partial().unwrap();
assert_eq!(collected.response.usage.unwrap().output_tokens, Some(5));
}
fn event_fixtures() -> Option<Vec<(String, Vec<StreamEvent>)>> {
fn visit(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for path in entries.map(|entry| entry.unwrap().path()) {
if path.is_dir() {
visit(&path, out);
} else if path.to_string_lossy().ends_with("_events.json") {
out.push(path);
}
}
}
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let workspace = std::fs::read_to_string(crates.join("../Cargo.toml"))
.is_ok_and(|manifest| manifest.contains("[workspace]"));
if !workspace {
eprintln!("skipped: the workspace stream fixtures are not available");
return None;
}
let mut paths = Vec::new();
for entry in std::fs::read_dir(&crates).unwrap() {
visit(&entry.unwrap().path().join("fixtures"), &mut paths);
}
paths.sort();
assert!(paths.len() >= 10, "found {} fixtures", paths.len());
let fixtures = paths
.into_iter()
.map(|path| {
let text = std::fs::read_to_string(&path).unwrap();
let items: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
let events = items
.into_iter()
.take_while(|item| item.get("error").is_none())
.map(|item| {
serde_json::from_value(item)
.unwrap_or_else(|err| panic!("{}: {err}", path.display()))
})
.collect();
(path.display().to_string(), events)
})
.collect();
Some(fixtures)
}
fn collect_both(events: &[StreamEvent]) -> (CollectedResponse, CollectedResponse) {
let mut full = StreamCollector::new();
let mut metadata = StreamCollector::metadata_only();
for event in events {
let _ = full.push_ref(event);
metadata.push_ref(event).unwrap();
}
(
full.finish_partial().unwrap(),
metadata.finish_partial().unwrap(),
)
}
fn assert_same_metadata(label: &str, events: &[StreamEvent]) {
let (full, metadata) = collect_both(events);
assert!(metadata.response.content.is_empty(), "{label}");
assert_eq!(
metadata.response.usage, full.response.usage,
"{label}: usage"
);
assert_eq!(
metadata.response.finish_reason, full.response.finish_reason,
"{label}: finish reason"
);
assert_eq!(metadata.response.id, full.response.id, "{label}: id");
assert_eq!(
metadata.response.model, full.response.model,
"{label}: model"
);
let mut expected = full.response;
expected.content.clear();
assert_eq!(metadata.response, expected, "{label}");
}
#[test]
fn metadata_only_collection_matches_full_collection_on_fixtures() {
let Some(fixtures) = event_fixtures() else {
return;
};
let mut with_usage = 0;
for (label, events) in &fixtures {
assert_same_metadata(label, events);
if collect_both(events).1.response.usage.is_some() {
with_usage += 1;
}
}
assert!(with_usage > 0);
}
#[test]
fn metadata_only_collection_matches_full_collection_in_delta_usage_mode() {
for (label, mut events) in event_fixtures().unwrap_or_default() {
for event in &mut events {
if let StreamEvent::ResponseMetadata { usage_mode, .. } = event {
*usage_mode = UsageMetadataMode::Delta;
}
}
assert_same_metadata(&format!("{label} (delta)"), &events);
}
}
#[test]
fn metadata_only_collection_merges_metadata_and_skips_blocks() {
let delta = |usage: Usage| {
let mut event = StreamEvent::metadata();
if let StreamEvent::ResponseMetadata {
usage: slot,
usage_mode,
..
} = &mut event
{
*slot = Some(usage);
*usage_mode = UsageMetadataMode::Delta;
}
event
};
let mut finish = StreamEvent::metadata();
if let StreamEvent::ResponseMetadata {
finish_reason,
model,
provider,
..
} = &mut finish
{
*finish_reason = Some(FinishReason::Length);
*model = Some("m-2".into());
*provider = Some("demo".into());
}
let mut events = vec![
StreamEvent::response_start(Some("r1".into()), Some("m".into())),
delta(Usage::new().input_tokens(4).output_tokens(1)),
];
events.extend(sample_response().into_stream_events().filter(|event| {
!matches!(
event,
StreamEvent::ResponseStart { .. }
| StreamEvent::ResponseMetadata { .. }
| StreamEvent::ResponseStop
)
}));
events.push(StreamEvent::text_delta(9, "dangling"));
events.push(StreamEvent::block_stop(42));
events.push(delta(Usage::new().output_tokens(2)));
events.push(finish);
events.push(StreamEvent::ResponseStop);
let mut collector = StreamCollector::metadata_only();
for event in &events {
assert!(collector.push_ref(event).unwrap().is_none());
}
assert!(
collector
.push_ref(&StreamEvent::text_delta(0, "late"))
.is_err()
);
let collected = collector.finish_partial().unwrap();
let response = &collected.response;
assert!(response.content.is_empty());
assert_eq!(
response.usage,
Some(Usage::new().input_tokens(4).output_tokens(3))
);
assert_eq!(response.finish_reason, Some(FinishReason::Length));
assert_eq!(response.id.as_deref(), Some("r1"));
assert_eq!(response.model.as_deref(), Some("m-2"));
assert_eq!(response.provider.as_deref(), Some("demo"));
assert_eq!(response.warnings, sample_response().warnings);
assert!(collected.completeness.is_complete());
}
#[tokio::test]
async fn until_committed_buffers_and_replays() {
let events = sample_response().into_stream();
let prefixed = until_committed(events).await.unwrap();
let all: Vec<_> = prefixed.map(|e| e.unwrap()).collect().await;
assert_eq!(
all,
sample_response().into_stream_events().collect::<Vec<_>>()
);
}
#[tokio::test]
async fn until_committed_reports_pre_output_errors() {
let stream = futures_util::stream::iter(vec![
Ok(StreamEvent::ResponseStart {
id: None,
model: None,
}),
Err(Error::overloaded("busy")),
]);
let err = until_committed(stream).await.err().unwrap();
assert!(matches!(err, Error::Overloaded { .. }));
}
#[tokio::test]
async fn until_committed_passes_post_output_errors_through() {
let stream = futures_util::stream::iter(vec![
Ok(StreamEvent::ResponseStart {
id: None,
model: None,
}),
Ok(StreamEvent::TextDelta {
index: 0,
text: "x".into(),
}),
Err(Error::overloaded("busy")),
]);
let prefixed = until_committed(stream).await.unwrap();
let items: Vec<_> = prefixed.collect().await;
assert_eq!(items.len(), 3);
assert!(items[2].is_err());
}
#[tokio::test]
async fn into_parts_returns_the_unyielded_prefix_and_the_rest() {
let start = StreamEvent::response_start(None, None);
let first = StreamEvent::text_delta(0, "a");
let second = StreamEvent::text_delta(0, "b");
let rest = futures_util::stream::iter(vec![Ok(second.clone())]);
let mut prefixed = Prefixed::new([start.clone(), first.clone()], Some(rest));
assert_eq!(prefixed.next().await.unwrap().unwrap(), start);
let (buffered, rest) = prefixed.into_parts();
assert_eq!(buffered, std::slice::from_ref(&first));
let resumed: Vec<_> = Prefixed::new(buffered, rest)
.map(Result::unwrap)
.collect()
.await;
assert_eq!(resumed, [first, second]);
}
#[tokio::test]
async fn into_parts_of_an_ended_stream_has_no_rest() {
let events = futures_util::stream::iter(vec![Ok(StreamEvent::response_start(None, None))]);
let (buffered, rest) = until_committed(events).await.unwrap().into_parts();
assert_eq!(buffered.len(), 1);
assert!(rest.is_none());
}
#[test]
fn metadata_builder_sets_every_field_of_the_event() {
let provider_data = ProviderData::new().with_raw("demo", "k", serde_json::json!(1));
let usage = Usage::new().input_tokens(4).output_tokens(2);
let built: StreamEvent = MetadataBuilder::new()
.with_finish_reason(FinishReason::Length)
.with_finish_reason_raw("max_tokens")
.with_usage(usage.clone())
.with_usage_mode(UsageMetadataMode::Delta)
.with_id("r1".to_owned())
.with_model("m")
.with_request_id_opt(None)
.with_provider("demo")
.with_provider_data(provider_data.clone())
.into();
let mut expected = StreamEvent::metadata();
if let StreamEvent::ResponseMetadata {
finish_reason,
finish_reason_raw,
usage: slot_usage,
usage_mode,
id,
model,
provider,
provider_data: slot_provider_data,
..
} = &mut expected
{
*finish_reason = Some(FinishReason::Length);
*finish_reason_raw = Some("max_tokens".into());
*slot_usage = Some(usage);
*usage_mode = UsageMetadataMode::Delta;
*id = Some("r1".into());
*model = Some("m".into());
*provider = Some("demo".into());
*slot_provider_data = provider_data;
}
assert_eq!(built, expected);
assert_eq!(MetadataBuilder::default().build(), StreamEvent::metadata());
}
#[test]
fn metadata_builder_opt_twins_keep_the_current_value_on_none() {
let set = MetadataBuilder::new()
.with_finish_reason(FinishReason::Stop)
.with_finish_reason_raw("end_turn")
.with_usage(Usage::new().output_tokens(1))
.with_id("r1")
.with_model("m")
.with_request_id("req_1")
.with_provider("demo");
let kept = set
.clone()
.with_finish_reason_opt(None)
.with_finish_reason_raw_opt(None)
.with_usage_opt(None)
.with_id_opt(None)
.with_model_opt(None)
.with_request_id_opt(None)
.with_provider_opt(None);
assert_eq!(kept, set);
let wire_id: Option<String> = Some("r2".to_owned());
let StreamEvent::ResponseMetadata { id, .. } = set.with_id_opt(wire_id.as_deref()).build()
else {
unreachable!()
};
assert_eq!(id.as_deref(), Some("r2"));
let served_by = Some(Cow::Owned("compat:groq".to_owned()));
let StreamEvent::ResponseMetadata { provider, .. } =
MetadataBuilder::new().with_provider_opt(served_by).build()
else {
unreachable!()
};
assert_eq!(provider.as_deref(), Some("compat:groq"));
}
#[test]
fn image_media_type_survives_the_stream_events() {
let image =
Image::new(MediaSource::url("https://example.com/a")).with_media_type("image/jpeg");
let response = ChatResponse::new(vec![ContentBlock::Image(image.clone())]);
let mut collector = StreamCollector::new();
for event in response.into_stream_events() {
collector.push(event).unwrap();
}
let collected = collector.finish().unwrap();
assert_eq!(collected.content, vec![ContentBlock::Image(image)]);
}
}