1use std::collections::BTreeMap;
2
3use base64::{Engine as _, engine::general_purpose::STANDARD};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::{
8 ContentPart, DEFAULT_MAX_ARTIFACT_BYTES, FinishReason, MediaSource, ModelError, ModelErrorKind,
9 ModelRef, ModelResponse, ModelUsage, ModelWarning, ProviderData, ReasoningPart, ToolCall,
10};
11
12#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
14#[serde(tag = "type", rename_all = "snake_case")]
15#[non_exhaustive]
16pub enum ContentBlockKind {
17 Text,
19 Reasoning {
21 signature: Option<String>,
23 redacted: bool,
25 },
26 ToolCall {
28 id: String,
30 name: String,
32 },
33 Refusal,
35 Image {
37 media_type: String,
39 },
40 Audio {
42 media_type: String,
44 },
45 Document {
47 media_type: String,
49 name: Option<String>,
51 },
52}
53
54#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
56pub struct ProviderEvent {
57 pub provider: String,
59 pub name: String,
61 pub payload: Value,
63}
64
65#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
67#[serde(tag = "type", rename_all = "snake_case")]
68#[non_exhaustive]
69pub enum ModelStreamEvent {
70 ResponseStarted {
72 id: Option<String>,
74 model: ModelRef,
76 },
77 ContentBlockStarted {
79 index: u32,
81 kind: ContentBlockKind,
83 },
84 TextDelta {
86 index: u32,
88 text: String,
90 },
91 ReasoningDelta {
93 index: u32,
95 text: String,
97 },
98 ReasoningSignatureDelta {
100 index: u32,
102 signature: String,
104 },
105 ToolArgumentsDelta {
107 index: u32,
109 json: String,
111 },
112 RefusalDelta {
114 index: u32,
116 text: String,
118 },
119 BinaryDelta {
121 index: u32,
123 data: String,
125 },
126 ContentBlockCompleted {
128 index: u32,
130 },
131 ContentPartCompleted {
133 index: u32,
135 part: ContentPart,
137 },
138 UsageUpdated {
140 usage: ModelUsage,
142 },
143 Warning {
145 warning: ModelWarning,
147 },
148 Heartbeat,
150 Provider {
152 event: ProviderEvent,
154 },
155 ResponseCompleted {
157 finish_reason: FinishReason,
159 provider_metadata: BTreeMap<String, Value>,
161 },
162 ToolArgumentsCompleted {
164 index: u32,
166 json: String,
168 },
169 ContentBlockMetadata {
175 index: u32,
177 metadata: BTreeMap<String, Value>,
179 },
180}
181
182#[derive(Debug)]
183enum PartialBlock {
184 Text(String),
185 Reasoning {
186 text: String,
187 signature: Option<String>,
188 redacted: bool,
189 },
190 ToolCall {
191 id: String,
192 name: String,
193 arguments: String,
194 metadata: BTreeMap<String, Value>,
195 },
196 Refusal(String),
197 Media {
198 kind: PartialMediaKind,
199 bytes: Vec<u8>,
200 },
201}
202
203#[derive(Debug)]
204enum PartialMediaKind {
205 Image {
206 media_type: String,
207 },
208 Audio {
209 media_type: String,
210 },
211 Document {
212 media_type: String,
213 name: Option<String>,
214 },
215}
216
217impl PartialBlock {
218 fn from_kind(kind: ContentBlockKind) -> Self {
219 match kind {
220 ContentBlockKind::Text => Self::Text(String::new()),
221 ContentBlockKind::Reasoning {
222 signature,
223 redacted,
224 } => Self::Reasoning {
225 text: String::new(),
226 signature,
227 redacted,
228 },
229 ContentBlockKind::ToolCall { id, name } => Self::ToolCall {
230 id,
231 name,
232 arguments: String::new(),
233 metadata: BTreeMap::new(),
234 },
235 ContentBlockKind::Refusal => Self::Refusal(String::new()),
236 ContentBlockKind::Image { media_type } => Self::Media {
237 kind: PartialMediaKind::Image { media_type },
238 bytes: Vec::new(),
239 },
240 ContentBlockKind::Audio { media_type } => Self::Media {
241 kind: PartialMediaKind::Audio { media_type },
242 bytes: Vec::new(),
243 },
244 ContentBlockKind::Document { media_type, name } => Self::Media {
245 kind: PartialMediaKind::Document { media_type, name },
246 bytes: Vec::new(),
247 },
248 }
249 }
250
251 fn complete(self) -> Result<ContentPart, ModelError> {
252 match self {
253 Self::Text(text) => Ok(ContentPart::Text { text }),
254 Self::Reasoning {
255 text,
256 signature,
257 redacted,
258 } => Ok(ContentPart::Reasoning(ReasoningPart {
259 text: (!text.is_empty()).then_some(text),
260 signature,
261 redacted,
262 provider_data: Vec::new(),
263 })),
264 Self::ToolCall {
265 id,
266 name,
267 arguments,
268 metadata,
269 } => {
270 let parsed = if arguments.trim().is_empty() {
271 serde_json::json!({})
272 } else {
273 serde_json::from_str(&arguments).map_err(|error| {
274 ModelError::local(
275 ModelErrorKind::MalformedToolArguments,
276 format!("tool call {id} returned invalid JSON arguments: {error}"),
277 )
278 })?
279 };
280 Ok(ContentPart::ToolCall(ToolCall {
281 id,
282 name,
283 arguments: parsed,
284 raw_arguments: Some(arguments),
285 metadata,
286 }))
287 }
288 Self::Refusal(text) => Ok(ContentPart::Refusal { text }),
289 Self::Media { kind, bytes } => {
290 let data = STANDARD.encode(bytes);
291 Ok(match kind {
292 PartialMediaKind::Image { media_type } => ContentPart::Image {
293 source: MediaSource::Base64 { media_type, data },
294 },
295 PartialMediaKind::Audio { media_type } => ContentPart::Audio {
296 source: MediaSource::Base64 { media_type, data },
297 },
298 PartialMediaKind::Document { media_type, name } => ContentPart::Document {
299 source: MediaSource::Base64 { media_type, data },
300 name,
301 },
302 })
303 }
304 }
305 }
306}
307
308#[derive(Debug, Default)]
310pub struct ModelStreamAccumulator {
311 started: bool,
312 completed: bool,
313 id: Option<String>,
314 model: Option<ModelRef>,
315 open_blocks: BTreeMap<u32, PartialBlock>,
316 content: BTreeMap<u32, ContentPart>,
317 usage: ModelUsage,
318 warnings: Vec<ModelWarning>,
319 provider_events: Vec<ProviderData>,
320}
321
322impl ModelStreamAccumulator {
323 pub fn new() -> Self {
325 Self::default()
326 }
327
328 pub fn push(&mut self, event: ModelStreamEvent) -> Result<Option<ModelResponse>, ModelError> {
336 if self.completed {
337 return Err(state_error("received an event after response completion"));
338 }
339
340 match event {
341 ModelStreamEvent::ResponseStarted { id, model } => self.start(id, model),
342 ModelStreamEvent::ContentBlockStarted { index, kind } => self.start_block(index, kind),
343 ModelStreamEvent::TextDelta { index, text } => {
344 match self.open_block_mut(index)? {
345 PartialBlock::Text(current) => current.push_str(&text),
346 _ => return Err(wrong_delta(index, "text")),
347 }
348 Ok(None)
349 }
350 ModelStreamEvent::ReasoningDelta { index, text } => {
351 match self.open_block_mut(index)? {
352 PartialBlock::Reasoning { text: current, .. } => current.push_str(&text),
353 _ => return Err(wrong_delta(index, "reasoning")),
354 }
355 Ok(None)
356 }
357 ModelStreamEvent::ReasoningSignatureDelta { index, signature } => {
358 match self.open_block_mut(index)? {
359 PartialBlock::Reasoning {
360 signature: current, ..
361 } => current.get_or_insert_with(String::new).push_str(&signature),
362 _ => return Err(wrong_delta(index, "reasoning signature")),
363 }
364 Ok(None)
365 }
366 ModelStreamEvent::ToolArgumentsDelta { index, json } => {
367 self.update_tool_arguments(index, json, false)?;
368 Ok(None)
369 }
370 ModelStreamEvent::ToolArgumentsCompleted { index, json } => {
371 self.update_tool_arguments(index, json, true)?;
372 Ok(None)
373 }
374 ModelStreamEvent::ContentBlockMetadata { index, metadata } => {
375 self.merge_block_metadata(index, metadata)
376 }
377 ModelStreamEvent::RefusalDelta { index, text } => {
378 match self.open_block_mut(index)? {
379 PartialBlock::Refusal(current) => current.push_str(&text),
380 _ => return Err(wrong_delta(index, "refusal")),
381 }
382 Ok(None)
383 }
384 ModelStreamEvent::BinaryDelta { index, data } => {
385 let decoded = STANDARD.decode(data).map_err(|error| {
386 state_error(format!("binary delta {index} is invalid base64: {error}"))
387 })?;
388 match self.open_block_mut(index)? {
389 PartialBlock::Media { bytes, .. } => {
390 let next = bytes.len().checked_add(decoded.len()).ok_or_else(|| {
391 state_error(format!("binary block {index} size overflow"))
392 })?;
393 if next > DEFAULT_MAX_ARTIFACT_BYTES {
394 return Err(state_error(format!(
395 "binary block {index} exceeds the {DEFAULT_MAX_ARTIFACT_BYTES}-byte limit"
396 )));
397 }
398 bytes.extend_from_slice(&decoded);
399 }
400 _ => return Err(wrong_delta(index, "binary media")),
401 }
402 Ok(None)
403 }
404 ModelStreamEvent::ContentBlockCompleted { index } => self.complete_block(index),
405 ModelStreamEvent::ContentPartCompleted { index, part } => {
406 self.complete_part(index, part)
407 }
408 ModelStreamEvent::UsageUpdated { usage } => {
409 self.require_started()?;
410 self.usage = usage;
411 Ok(None)
412 }
413 ModelStreamEvent::Warning { warning } => {
414 self.require_started()?;
415 self.warnings.push(warning);
416 Ok(None)
417 }
418 ModelStreamEvent::Heartbeat => {
419 self.require_started()?;
420 Ok(None)
421 }
422 ModelStreamEvent::Provider { event } => {
423 self.require_started()?;
424 self.provider_events.push(ProviderData {
425 provider: event.provider,
426 kind: event.name,
427 value: event.payload,
428 });
429 Ok(None)
430 }
431 ModelStreamEvent::ResponseCompleted {
432 finish_reason,
433 provider_metadata,
434 } => self.complete(finish_reason, provider_metadata),
435 }
436 }
437
438 fn start(
439 &mut self,
440 id: Option<String>,
441 model: ModelRef,
442 ) -> Result<Option<ModelResponse>, ModelError> {
443 if self.started {
444 return Err(state_error("received more than one response-start event"));
445 }
446 self.started = true;
447 self.id = id;
448 self.model = Some(model);
449 Ok(None)
450 }
451
452 fn update_tool_arguments(
453 &mut self,
454 index: u32,
455 json: String,
456 complete: bool,
457 ) -> Result<(), ModelError> {
458 match self.open_block_mut(index)? {
459 PartialBlock::ToolCall { arguments, .. } if complete => *arguments = json,
460 PartialBlock::ToolCall { arguments, .. } => arguments.push_str(&json),
461 _ if complete => return Err(wrong_delta(index, "completed tool arguments")),
462 _ => return Err(wrong_delta(index, "tool arguments")),
463 }
464 Ok(())
465 }
466
467 fn start_block(
468 &mut self,
469 index: u32,
470 kind: ContentBlockKind,
471 ) -> Result<Option<ModelResponse>, ModelError> {
472 self.require_started()?;
473 self.require_unused_index(index)?;
474 self.open_blocks
475 .insert(index, PartialBlock::from_kind(kind));
476 Ok(None)
477 }
478
479 fn complete_block(&mut self, index: u32) -> Result<Option<ModelResponse>, ModelError> {
480 self.require_started()?;
481 let block = self
482 .open_blocks
483 .remove(&index)
484 .ok_or_else(|| state_error(format!("content block {index} is not open")))?;
485 self.content.insert(index, block.complete()?);
486 Ok(None)
487 }
488
489 fn complete_part(
490 &mut self,
491 index: u32,
492 part: ContentPart,
493 ) -> Result<Option<ModelResponse>, ModelError> {
494 self.require_started()?;
495 self.require_unused_index(index)?;
496 self.content.insert(index, part);
497 Ok(None)
498 }
499
500 fn merge_block_metadata(
501 &mut self,
502 index: u32,
503 metadata: BTreeMap<String, Value>,
504 ) -> Result<Option<ModelResponse>, ModelError> {
505 self.require_started()?;
506 if let Some(block) = self.open_blocks.get_mut(&index) {
507 return match block {
508 PartialBlock::ToolCall {
509 metadata: current, ..
510 } => {
511 current.extend(metadata);
512 Ok(None)
513 }
514 _ => Err(wrong_delta(index, "content metadata")),
515 };
516 }
517 match self.content.get_mut(&index) {
518 Some(ContentPart::ToolCall(call)) => {
519 call.metadata.extend(metadata);
520 Ok(None)
521 }
522 Some(_) => Err(wrong_delta(index, "content metadata")),
523 None => Err(state_error(format!(
524 "content metadata targeted unknown block {index}"
525 ))),
526 }
527 }
528
529 fn complete(
530 &mut self,
531 mut finish_reason: FinishReason,
532 provider_metadata: BTreeMap<String, Value>,
533 ) -> Result<Option<ModelResponse>, ModelError> {
534 self.require_started()?;
535 if !self.open_blocks.is_empty() {
536 let open = self
537 .open_blocks
538 .keys()
539 .map(u32::to_string)
540 .collect::<Vec<_>>()
541 .join(", ");
542 return Err(state_error(format!(
543 "response completed with open content blocks: {open}"
544 )));
545 }
546 self.completed = true;
547 let has_tool_calls = self
548 .content
549 .values()
550 .any(|part| matches!(part, ContentPart::ToolCall(_)));
551 if has_tool_calls && matches!(finish_reason, FinishReason::Stop) {
552 finish_reason = FinishReason::ToolCalls;
553 }
554 let model = self
555 .model
556 .clone()
557 .ok_or_else(|| state_error("response model is missing"))?;
558 Ok(Some(ModelResponse {
559 id: self.id.clone(),
560 model,
561 content: std::mem::take(&mut self.content).into_values().collect(),
562 finish_reason,
563 usage: self.usage,
564 warnings: std::mem::take(&mut self.warnings),
565 provider_metadata,
566 provider_events: std::mem::take(&mut self.provider_events),
567 }))
568 }
569
570 fn require_started(&self) -> Result<(), ModelError> {
571 if self.started {
572 Ok(())
573 } else {
574 Err(state_error("received content before response start"))
575 }
576 }
577
578 fn require_unused_index(&self, index: u32) -> Result<(), ModelError> {
579 if self.open_blocks.contains_key(&index) || self.content.contains_key(&index) {
580 Err(state_error(format!(
581 "content block index {index} was already used"
582 )))
583 } else {
584 Ok(())
585 }
586 }
587
588 fn open_block_mut(&mut self, index: u32) -> Result<&mut PartialBlock, ModelError> {
589 self.require_started()?;
590 self.open_blocks
591 .get_mut(&index)
592 .ok_or_else(|| state_error(format!("content block {index} is not open")))
593 }
594}
595
596fn wrong_delta(index: u32, delta: &str) -> ModelError {
597 state_error(format!(
598 "{delta} delta does not match content block {index}"
599 ))
600}
601
602fn state_error(message: impl Into<String>) -> ModelError {
603 ModelError::local(ModelErrorKind::StreamState, message)
604}
605
606#[cfg(test)]
607mod tests {
608 use std::collections::BTreeMap;
609
610 use super::{ContentBlockKind, ModelStreamAccumulator, ModelStreamEvent, ProviderEvent};
611 use crate::{
612 ContentPart, FinishReason, MediaSource, ModelErrorKind, ModelRef, ModelUsage, ModelWarning,
613 ToolCall,
614 };
615
616 fn started() -> ModelStreamEvent {
617 ModelStreamEvent::ResponseStarted {
618 id: Some("response-1".into()),
619 model: ModelRef::new("test", "model"),
620 }
621 }
622
623 fn completed() -> ModelStreamEvent {
624 ModelStreamEvent::ResponseCompleted {
625 finish_reason: FinishReason::Stop,
626 provider_metadata: BTreeMap::new(),
627 }
628 }
629
630 #[test]
631 fn accumulates_ordered_text_and_tool_calls() {
632 let mut accumulator = ModelStreamAccumulator::new();
633 let events = [
634 started(),
635 ModelStreamEvent::ContentBlockStarted {
636 index: 1,
637 kind: ContentBlockKind::ToolCall {
638 id: "call-1".into(),
639 name: "search".into(),
640 },
641 },
642 ModelStreamEvent::ToolArgumentsDelta {
643 index: 1,
644 json: "{\"query\":".into(),
645 },
646 ModelStreamEvent::ContentBlockStarted {
647 index: 0,
648 kind: ContentBlockKind::Text,
649 },
650 ModelStreamEvent::TextDelta {
651 index: 0,
652 text: "I will search.".into(),
653 },
654 ModelStreamEvent::ToolArgumentsDelta {
655 index: 1,
656 json: "\"rust\"}".into(),
657 },
658 ModelStreamEvent::ContentBlockMetadata {
659 index: 1,
660 metadata: BTreeMap::from([("test.status".into(), serde_json::json!("completed"))]),
661 },
662 ModelStreamEvent::ContentBlockCompleted { index: 0 },
663 ModelStreamEvent::ContentBlockCompleted { index: 1 },
664 ModelStreamEvent::UsageUpdated {
665 usage: ModelUsage {
666 input_tokens: 5,
667 output_tokens: 3,
668 ..ModelUsage::default()
669 },
670 },
671 completed(),
672 ];
673
674 let response = events
675 .into_iter()
676 .find_map(|event| accumulator.push(event).unwrap())
677 .unwrap();
678
679 assert_eq!(response.content[0], ContentPart::text("I will search."));
680 assert_eq!(
681 response.content[1],
682 ContentPart::ToolCall(ToolCall {
683 id: "call-1".into(),
684 name: "search".into(),
685 arguments: serde_json::json!({"query": "rust"}),
686 raw_arguments: Some("{\"query\":\"rust\"}".into()),
687 metadata: BTreeMap::from([("test.status".into(), serde_json::json!("completed"),)]),
688 })
689 );
690 assert_eq!(response.usage.input_tokens, 5);
691 }
692
693 #[test]
694 fn preserves_provider_events_and_warnings() {
695 let mut accumulator = ModelStreamAccumulator::new();
696 accumulator.push(started()).unwrap();
697 accumulator
698 .push(ModelStreamEvent::Provider {
699 event: ProviderEvent {
700 provider: "test".into(),
701 name: "ping".into(),
702 payload: serde_json::json!({"alive": true}),
703 },
704 })
705 .unwrap();
706 accumulator
707 .push(ModelStreamEvent::Warning {
708 warning: ModelWarning {
709 code: "emulated".into(),
710 message: "structured output was emulated".into(),
711 metadata: BTreeMap::new(),
712 },
713 })
714 .unwrap();
715 let response = accumulator.push(completed()).unwrap().unwrap();
716
717 assert_eq!(response.provider_events.len(), 1);
718 assert_eq!(response.provider_events[0].kind, "ping");
719 assert_eq!(response.warnings.len(), 1);
720 }
721
722 #[test]
723 fn rejects_delta_without_matching_open_block() {
724 let mut accumulator = ModelStreamAccumulator::new();
725 accumulator.push(started()).unwrap();
726
727 let error = accumulator
728 .push(ModelStreamEvent::TextDelta {
729 index: 4,
730 text: "orphan".into(),
731 })
732 .unwrap_err();
733
734 assert_eq!(error.kind, ModelErrorKind::StreamState);
735 }
736
737 #[test]
738 fn rejects_completion_with_open_blocks() {
739 let mut accumulator = ModelStreamAccumulator::new();
740 accumulator.push(started()).unwrap();
741 accumulator
742 .push(ModelStreamEvent::ContentBlockStarted {
743 index: 0,
744 kind: ContentBlockKind::Text,
745 })
746 .unwrap();
747
748 let error = accumulator.push(completed()).unwrap_err();
749
750 assert_eq!(error.kind, ModelErrorKind::StreamState);
751 }
752
753 #[test]
754 fn rejects_malformed_tool_arguments() {
755 let mut accumulator = ModelStreamAccumulator::new();
756 accumulator.push(started()).unwrap();
757 accumulator
758 .push(ModelStreamEvent::ContentBlockStarted {
759 index: 0,
760 kind: ContentBlockKind::ToolCall {
761 id: "bad".into(),
762 name: "tool".into(),
763 },
764 })
765 .unwrap();
766 accumulator
767 .push(ModelStreamEvent::ToolArgumentsDelta {
768 index: 0,
769 json: "{invalid".into(),
770 })
771 .unwrap();
772
773 let error = accumulator
774 .push(ModelStreamEvent::ContentBlockCompleted { index: 0 })
775 .unwrap_err();
776
777 assert_eq!(error.kind, ModelErrorKind::MalformedToolArguments);
778 }
779
780 #[test]
781 fn completed_arguments_replace_partial_deltas() {
782 let mut accumulator = ModelStreamAccumulator::new();
783 accumulator.push(started()).unwrap();
784 accumulator
785 .push(ModelStreamEvent::ContentBlockStarted {
786 index: 0,
787 kind: ContentBlockKind::ToolCall {
788 id: "call".into(),
789 name: "tool".into(),
790 },
791 })
792 .unwrap();
793 accumulator
794 .push(ModelStreamEvent::ToolArgumentsDelta {
795 index: 0,
796 json: "{\"stale\":".into(),
797 })
798 .unwrap();
799 accumulator
800 .push(ModelStreamEvent::ToolArgumentsCompleted {
801 index: 0,
802 json: "{\"final\":true}".into(),
803 })
804 .unwrap();
805 accumulator
806 .push(ModelStreamEvent::ContentBlockCompleted { index: 0 })
807 .unwrap();
808 let response = accumulator.push(completed()).unwrap().unwrap();
809
810 let ContentPart::ToolCall(call) = &response.content[0] else {
811 panic!("fixture must produce a tool call");
812 };
813 assert_eq!(call.arguments, serde_json::json!({"final": true}));
814 assert_eq!(call.raw_arguments.as_deref(), Some("{\"final\":true}"));
815 }
816
817 #[test]
818 fn tool_content_normalizes_stop_reason() {
819 let mut accumulator = ModelStreamAccumulator::new();
820 accumulator.push(started()).unwrap();
821 accumulator
822 .push(ModelStreamEvent::ContentPartCompleted {
823 index: 0,
824 part: ContentPart::ToolCall(ToolCall {
825 id: "call".into(),
826 name: "tool".into(),
827 arguments: serde_json::json!({}),
828 raw_arguments: Some("{}".into()),
829 metadata: BTreeMap::new(),
830 }),
831 })
832 .unwrap();
833 let response = accumulator.push(completed()).unwrap().unwrap();
834
835 assert_eq!(response.finish_reason, FinishReason::ToolCalls);
836 }
837
838 #[test]
839 fn tool_content_does_not_hide_unknown_finish_reason() {
840 let mut accumulator = ModelStreamAccumulator::new();
841 accumulator
842 .push(ModelStreamEvent::ResponseStarted {
843 id: None,
844 model: ModelRef::new("test", "model"),
845 })
846 .unwrap();
847 accumulator
848 .push(ModelStreamEvent::ContentPartCompleted {
849 index: 0,
850 part: ContentPart::ToolCall(ToolCall {
851 id: "call-1".into(),
852 name: "lookup".into(),
853 arguments: serde_json::json!({}),
854 raw_arguments: Some("{}".into()),
855 metadata: BTreeMap::new(),
856 }),
857 })
858 .unwrap();
859 let response = accumulator
860 .push(ModelStreamEvent::ResponseCompleted {
861 finish_reason: FinishReason::Unknown,
862 provider_metadata: BTreeMap::new(),
863 })
864 .unwrap()
865 .unwrap();
866
867 assert_eq!(response.finish_reason, FinishReason::Unknown);
868 }
869
870 #[test]
871 fn explicit_failure_reason_is_not_hidden_by_tool_content() {
872 let mut accumulator = ModelStreamAccumulator::new();
873 accumulator.push(started()).unwrap();
874 accumulator
875 .push(ModelStreamEvent::ContentPartCompleted {
876 index: 0,
877 part: ContentPart::ToolCall(ToolCall {
878 id: "partial".into(),
879 name: "tool".into(),
880 arguments: serde_json::json!({}),
881 raw_arguments: Some("{}".into()),
882 metadata: BTreeMap::new(),
883 }),
884 })
885 .unwrap();
886 let response = accumulator
887 .push(ModelStreamEvent::ResponseCompleted {
888 finish_reason: FinishReason::Length,
889 provider_metadata: BTreeMap::new(),
890 })
891 .unwrap()
892 .unwrap();
893
894 assert_eq!(response.finish_reason, FinishReason::Length);
895 }
896
897 #[test]
898 fn accumulates_bounded_binary_media_chunks() {
899 let mut accumulator = ModelStreamAccumulator::new();
900 accumulator.push(started()).unwrap();
901 accumulator
902 .push(ModelStreamEvent::ContentBlockStarted {
903 index: 0,
904 kind: ContentBlockKind::Image {
905 media_type: "image/png".into(),
906 },
907 })
908 .unwrap();
909 accumulator
910 .push(ModelStreamEvent::BinaryDelta {
911 index: 0,
912 data: "cG5n".into(),
913 })
914 .unwrap();
915 accumulator
916 .push(ModelStreamEvent::ContentBlockCompleted { index: 0 })
917 .unwrap();
918 let response = accumulator.push(completed()).unwrap().unwrap();
919
920 assert!(matches!(
921 &response.content[0],
922 ContentPart::Image {
923 source: MediaSource::Base64 { media_type, data }
924 } if media_type == "image/png" && data == "cG5n"
925 ));
926 }
927}