1use crate::types::assistant_content::{PROVIDER_NAME, map_stop_reason, normalize_usage};
2use crate::types::completion_request::AwsCompletionRequest;
3use crate::types::converse_output::{StopReason, TokenUsage};
4use crate::{
5 completion::{CompletionModel, resolve_request_model},
6 types::errors::{AwsSdkConverseStreamError, converse_stream_output_completion_error},
7};
8use async_stream::stream;
9use aws_sdk_bedrockruntime::types as aws_bedrock;
10use base64::{Engine, prelude::BASE64_STANDARD};
11use rig_core::providers::internal::adapter::{AdapterOutput, WireAdapter, run_wire_stream};
12use rig_core::providers::internal::tool_call_bridge::ToolCallBridge;
13use rig_core::providers::internal::wire::{self, TypedEvent, WireEvent};
14use rig_core::streaming::StreamingCompletionResponse;
15use rig_core::telemetry::{CompletionOperation, CompletionSpanBuilder, SpanCombinator};
16use rig_core::{
17 completion::CompletionError,
18 message::ReasoningContent,
19 streaming::{RawStreamingChoice, ToolCallDeltaContent, UnparseableToolInput},
20 wasm_compat::WasmCompatSend,
21};
22use serde::{Deserialize, Serialize};
23use tracing_futures::Instrument;
24
25#[derive(Clone, Deserialize, Serialize)]
26pub struct BedrockStreamingResponse {
27 pub usage: Option<TokenUsage>,
28 #[serde(skip_serializing_if = "Option::is_none")]
31 pub stop_reason: Option<StopReason>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub provider_request_id: Option<String>,
38}
39
40impl From<&BedrockStreamingResponse> for rig_core::completion::Usage {
41 fn from(response: &BedrockStreamingResponse) -> Self {
42 response
43 .usage
44 .as_ref()
45 .map(normalize_usage)
46 .unwrap_or_default()
47 }
48}
49
50#[derive(Default)]
51struct ReasoningState {
52 signature: Option<String>,
57}
58
59fn block_id(content_block_index: i32) -> rig_core::streaming::StreamPartId {
67 let index = (i64::from(content_block_index) - i64::from(i32::MIN)) as u64;
68 rig_core::streaming::MintKind::Block.for_wire_index(index)
69}
70
71fn reasoning_end(
82 state: ReasoningState,
83 content_block_index: i32,
84) -> RawStreamingChoice<BedrockStreamingResponse> {
85 RawStreamingChoice::ReasoningEnd {
86 id: block_id(content_block_index),
89 reasoning: None,
90 signature: state.signature,
91 wire_sent: true,
95 }
96}
97
98#[derive(Default)]
109struct StreamState {
110 tool_calls: ToolCallBridge<i32>,
111 current_reasoning: Option<ReasoningState>,
112 final_stop_reason: Option<StopReason>,
113}
114
115fn stop_reason_label(stop_reason: &StopReason) -> &'static str {
119 match stop_reason {
120 StopReason::ContentFiltered => "content_filtered",
121 StopReason::EndTurn => "end_turn",
122 StopReason::GuardrailIntervened => "guardrail_intervened",
123 StopReason::MaxTokens => "max_tokens",
124 StopReason::StopSequence => "stop_sequence",
125 StopReason::ToolUse => "tool_use",
126 StopReason::Unknown(_) => "other",
127 }
128}
129
130fn process_event(
135 state: &mut StreamState,
136 output: aws_bedrock::ConverseStreamOutput,
137) -> Vec<Result<RawStreamingChoice<BedrockStreamingResponse>, CompletionError>> {
138 let mut items = Vec::new();
139 match output {
140 aws_bedrock::ConverseStreamOutput::ContentBlockDelta(event) => {
141 let Some(delta) = event.delta else {
142 tracing::warn!("skipping ContentBlockDelta with a missing delta");
143 return items;
144 };
145 match delta {
146 aws_bedrock::ContentBlockDelta::Text(text) => {
147 items.push(Ok(RawStreamingChoice::Message(text)));
148 }
149 aws_bedrock::ContentBlockDelta::ToolUse(tool) => {
150 if let Some(tool_call) = state.tool_calls.get(event.content_block_index) {
151 items.push(Ok(RawStreamingChoice::ToolCallDelta {
154 id: tool_call.key().to_owned(),
155 content: ToolCallDeltaContent::Delta(tool.input().to_string()),
156 }));
157 }
158 }
159 aws_bedrock::ContentBlockDelta::ReasoningContent(reasoning) => match reasoning {
160 aws_bedrock::ReasoningContentBlockDelta::Text(text) => {
161 state
164 .current_reasoning
165 .get_or_insert_with(ReasoningState::default);
166
167 if !text.is_empty() {
168 items.push(Ok(RawStreamingChoice::ReasoningDelta {
169 reasoning: text.clone(),
170 id: block_id(event.content_block_index),
173 provider_id: None,
174 }));
175 }
176 }
177 aws_bedrock::ReasoningContentBlockDelta::Signature(signature) => {
178 state
179 .current_reasoning
180 .get_or_insert_with(ReasoningState::default)
181 .signature = Some(signature.clone());
182 }
183 aws_bedrock::ReasoningContentBlockDelta::RedactedContent(blob) => {
184 if let Some(open) = state.current_reasoning.take() {
191 items.push(Ok(reasoning_end(open, event.content_block_index)));
192 }
193
194 items.push(Ok(RawStreamingChoice::Reasoning {
195 id: block_id(event.content_block_index),
199 provider_id: None,
200 content: ReasoningContent::Redacted {
201 data: BASE64_STANDARD.encode(blob.as_ref()),
206 },
207 }));
208 }
209 unknown => {
210 tracing::warn!(
211 delta = ?std::mem::discriminant(&unknown),
212 "skipping unrecognized Bedrock reasoning content delta variant"
213 );
214 }
215 },
216 unknown => {
217 tracing::warn!(
218 delta = ?std::mem::discriminant(&unknown),
219 "skipping unrecognized Bedrock content block delta variant"
220 );
221 }
222 }
223 }
224 aws_bedrock::ConverseStreamOutput::ContentBlockStart(event) => {
225 let Some(start) = event.start else {
226 tracing::warn!("skipping ContentBlockStart with no data");
227 return items;
228 };
229 match start {
230 aws_bedrock::ContentBlockStart::ToolUse(tool_use) => {
231 let slot = state.tool_calls.open(
235 event.content_block_index,
236 Some(&tool_use.tool_use_id),
237 Some(&tool_use.name),
238 );
239 items.push(Ok(RawStreamingChoice::ToolCallDelta {
240 id: slot.key().to_owned(),
241 content: ToolCallDeltaContent::Name(tool_use.name),
242 }));
243 }
244 unknown => tracing::warn!(
251 start = ?std::mem::discriminant(&unknown),
252 "skipping unrecognized Bedrock ContentBlockStart variant"
253 ),
254 }
255 }
256 aws_bedrock::ConverseStreamOutput::ContentBlockStop(event) => {
257 if let Some(reasoning_state) = state.current_reasoning.take() {
258 items.push(Ok(reasoning_end(
259 reasoning_state,
260 event.content_block_index,
261 )));
262 }
263 if let Some(tool_call) = state.tool_calls.remove(event.content_block_index) {
272 items.push(Ok(RawStreamingChoice::ToolInputEnd(
273 tool_call.end_event(UnparseableToolInput::Error),
274 )));
275 }
276 }
277 aws_bedrock::ConverseStreamOutput::MessageStop(message_stop_event) => {
278 state.final_stop_reason = Some(
282 StopReason::try_from(message_stop_event.stop_reason.clone()).unwrap_or_else(|_| {
283 StopReason::Unknown(crate::types::converse_output::UnknownVariantValue(
284 message_stop_event.stop_reason.as_str().to_owned(),
285 ))
286 }),
287 );
288 if matches!(state.final_stop_reason, Some(StopReason::ToolUse)) {
298 for tool_call in state.tool_calls.drain_ordered() {
299 items.push(Ok(RawStreamingChoice::ToolInputEnd(
300 tool_call.end_event(UnparseableToolInput::Error),
301 )));
302 }
303 } else if !state.tool_calls.is_empty() {
304 let dropped = state.tool_calls.drain_ordered().len();
310 tracing::warn!(
311 dropped_tool_calls = dropped,
312 stop_reason = state
313 .final_stop_reason
314 .as_ref()
315 .map_or("none", stop_reason_label),
316 "dropping unfinished tool-use blocks left in flight at MessageStop"
317 );
318 }
319 }
320 aws_bedrock::ConverseStreamOutput::Metadata(metadata_event) => {
321 let final_response = BedrockStreamingResponse {
324 usage: metadata_event
326 .usage
327 .and_then(|usage| TokenUsage::try_from(usage).ok()),
328 stop_reason: state.final_stop_reason.clone(),
329 provider_request_id: None,
332 };
333 items.push(Ok(RawStreamingChoice::FinalResponse(final_response)));
334 }
335 _ => {}
336 }
337 items
338}
339
340impl WireAdapter for StreamState {
341 type Frame = aws_bedrock::ConverseStreamOutput;
342 type Event = aws_bedrock::ConverseStreamOutput;
343 type Response = BedrockStreamingResponse;
344
345 fn classify(&self, frame: Self::Frame) -> WireEvent<Self::Event> {
346 wire::classify_typed_event(if frame.is_unknown() {
352 TypedEvent::Unrecognized {
353 event_type: "unknown".to_string(),
354 detail: format!("{frame:?}"),
355 }
356 } else {
357 TypedEvent::Modeled(frame)
358 })
359 }
360
361 fn interpret(&mut self, event: Self::Event, out: &mut AdapterOutput<Self::Response>) {
362 for item in process_event(self, event) {
363 if let Ok(RawStreamingChoice::FinalResponse(final_response)) = &item {
364 tracing::Span::current().record_token_usage(&final_response.into());
365 }
366 out.push(item);
367 }
368 }
369
370 fn finish(&mut self, _out: &mut AdapterOutput<Self::Response>) {
371 }
374}
375
376pub fn stream_from_events(
383 events: impl futures::Stream<Item = Result<aws_bedrock::ConverseStreamOutput, CompletionError>>
384 + WasmCompatSend
385 + 'static,
386) -> StreamingCompletionResponse {
387 let raw = run_wire_stream(events, StreamState::default());
388 StreamingCompletionResponse::stream(PROVIDER_NAME, normalize_bedrock_stream(raw))
389}
390
391fn normalize_bedrock_stream(
392 raw: rig_core::streaming::RawStreamingResult<BedrockStreamingResponse>,
393) -> rig_core::streaming::StreamingResult {
394 rig_core::streaming::normalize_stream(raw, |response| {
395 let usage = (&response).into();
396 let finish_reason = response.stop_reason.as_ref().map(map_stop_reason);
397 Ok(rig_core::streaming::StreamFinal::new(PROVIDER_NAME, usage)
398 .with_optional_provider_request_id(response.provider_request_id.clone())
399 .with_optional_finish_reason(finish_reason))
400 })
401}
402
403impl CompletionModel {
404 pub async fn raw_stream(
406 &self,
407 completion_request: rig_core::completion::CompletionRequest,
408 ) -> Result<rig_core::streaming::RawStreamingResult<BedrockStreamingResponse>, CompletionError>
409 {
410 let request_model = resolve_request_model(&self.model, &completion_request);
411 let system_instructions = completion_request.preamble.clone();
412 let record_telemetry_content = completion_request.record_telemetry_content;
413 let request = AwsCompletionRequest {
414 inner: completion_request,
415 prompt_caching: self.prompt_caching,
416 };
417 let span = CompletionSpanBuilder::new(
418 "aws_bedrock",
419 &request_model,
420 CompletionOperation::ChatStreaming,
421 )
422 .system_instructions(system_instructions.as_deref(), record_telemetry_content)
423 .build();
424
425 let mut converse_builder = self
426 .client
427 .get_inner()
428 .await
429 .converse_stream()
430 .model_id(request_model);
431
432 let tool_config = request.tools_config()?;
433 let prompt_with_history = request.messages()?;
434 let output_config = request.output_config()?;
435 converse_builder = converse_builder
436 .set_additional_model_request_fields(request.additional_params())
437 .set_inference_config(request.inference_config())
438 .set_tool_config(tool_config)
439 .set_system(request.system_prompt()?)
440 .set_messages(Some(prompt_with_history))
441 .set_output_config(output_config);
442
443 let response = converse_builder
444 .send()
445 .instrument(span.clone())
446 .await
447 .map_err(|sdk_error| {
448 Into::<CompletionError>::into(AwsSdkConverseStreamError(sdk_error))
449 })?;
450
451 let provider_request_id =
455 aws_sdk_bedrockruntime::operation::RequestId::request_id(&response).map(str::to_string);
456
457 let transport = stream! {
461 let mut stream = response.stream;
462 loop {
463 match stream.recv().await {
464 Ok(Some(output)) => yield Ok(output),
465 Ok(None) => break,
466 Err(err) => {
467 yield Err(converse_stream_output_completion_error(err.into_service_error()));
468 break;
469 }
470 }
471 }
472 };
473
474 use futures::StreamExt as _;
477 let stream = run_wire_stream(transport, StreamState::default()).instrument(span);
478 Ok(Box::pin(stream.map(move |item| {
479 item.map(|choice| match choice {
480 RawStreamingChoice::FinalResponse(mut response) => {
481 response.provider_request_id = provider_request_id.clone();
482 RawStreamingChoice::FinalResponse(response)
483 }
484 other => other,
485 })
486 })))
487 }
488
489 pub(crate) async fn stream(
492 &self,
493 completion_request: rig_core::completion::CompletionRequest,
494 ) -> Result<StreamingCompletionResponse, CompletionError> {
495 let raw = self.raw_stream(completion_request).await?;
496
497 Ok(StreamingCompletionResponse::stream(
498 PROVIDER_NAME,
499 normalize_bedrock_stream(raw),
500 ))
501 }
502}
503
504#[cfg(test)]
505#[allow(clippy::expect_used, clippy::panic)]
506mod tests {
507 use super::*;
508 use futures::StreamExt;
509 use rig_core::message::Reasoning;
510 use rig_core::streaming::StreamedAssistantContent;
511
512 fn reasoning_text_delta(index: i32, text: &str) -> aws_bedrock::ConverseStreamOutput {
515 aws_bedrock::ConverseStreamOutput::ContentBlockDelta(
516 aws_bedrock::ContentBlockDeltaEvent::builder()
517 .content_block_index(index)
518 .delta(aws_bedrock::ContentBlockDelta::ReasoningContent(
519 aws_bedrock::ReasoningContentBlockDelta::Text(text.to_string()),
520 ))
521 .build()
522 .expect("reasoning text delta should build"),
523 )
524 }
525
526 fn reasoning_signature_delta(index: i32, signature: &str) -> aws_bedrock::ConverseStreamOutput {
527 aws_bedrock::ConverseStreamOutput::ContentBlockDelta(
528 aws_bedrock::ContentBlockDeltaEvent::builder()
529 .content_block_index(index)
530 .delta(aws_bedrock::ContentBlockDelta::ReasoningContent(
531 aws_bedrock::ReasoningContentBlockDelta::Signature(signature.to_string()),
532 ))
533 .build()
534 .expect("reasoning signature delta should build"),
535 )
536 }
537
538 fn reasoning_redacted_delta(index: i32, blob: &[u8]) -> aws_bedrock::ConverseStreamOutput {
539 aws_bedrock::ConverseStreamOutput::ContentBlockDelta(
540 aws_bedrock::ContentBlockDeltaEvent::builder()
541 .content_block_index(index)
542 .delta(aws_bedrock::ContentBlockDelta::ReasoningContent(
543 aws_bedrock::ReasoningContentBlockDelta::RedactedContent(
544 aws_smithy_types::Blob::new(blob.to_vec()),
545 ),
546 ))
547 .build()
548 .expect("redacted reasoning delta should build"),
549 )
550 }
551
552 fn block_stop(index: i32) -> aws_bedrock::ConverseStreamOutput {
553 aws_bedrock::ConverseStreamOutput::ContentBlockStop(
554 aws_bedrock::ContentBlockStopEvent::builder()
555 .content_block_index(index)
556 .build()
557 .expect("content block stop should build"),
558 )
559 }
560
561 fn terminal() -> Vec<aws_bedrock::ConverseStreamOutput> {
562 vec![
563 aws_bedrock::ConverseStreamOutput::MessageStop(
564 aws_bedrock::MessageStopEvent::builder()
565 .stop_reason(aws_bedrock::StopReason::EndTurn)
566 .build()
567 .expect("message stop should build"),
568 ),
569 aws_bedrock::ConverseStreamOutput::Metadata(
570 aws_bedrock::ConverseStreamMetadataEvent::builder().build(),
571 ),
572 ]
573 }
574
575 struct Drained {
576 reasoning: Vec<Reasoning>,
577 errors: Vec<String>,
578 reached_terminal: bool,
579 }
580
581 async fn drain(events: Vec<aws_bedrock::ConverseStreamOutput>) -> Drained {
582 let mut stream = stream_from_events(futures::stream::iter(events.into_iter().map(Ok)));
583 let mut drained = Drained {
584 reasoning: Vec::new(),
585 errors: Vec::new(),
586 reached_terminal: false,
587 };
588
589 while let Some(item) = stream.next().await {
590 match item {
591 Ok(StreamedAssistantContent::Reasoning { reasoning, .. }) => {
592 drained.reasoning.push(reasoning);
593 }
594 Ok(StreamedAssistantContent::Final(_)) => drained.reached_terminal = true,
595 Ok(_) => {}
596 Err(error) => drained.errors.push(error.to_string()),
597 }
598 }
599
600 drained
601 }
602
603 #[tokio::test]
609 async fn thinking_then_text_streams_through_the_driver_without_violation() {
610 let drained = drain(vec![
611 reasoning_text_delta(0, "let me think"),
612 block_stop(0),
613 text_delta_event(1, "the answer"),
614 block_stop_event(1),
615 message_stop_event(aws_bedrock::StopReason::EndTurn),
616 ])
617 .await;
618 assert!(drained.errors.is_empty(), "{:?}", drained.errors);
619 assert_eq!(drained.reasoning.len(), 1);
620 assert_eq!(
621 drained
622 .reasoning
623 .iter()
624 .flat_map(|reasoning| reasoning.content.iter())
625 .cloned()
626 .collect::<Vec<_>>(),
627 vec![ReasoningContent::Text {
628 text: "let me think".to_string(),
629 signature: None,
630 }],
631 "an unsigned block closes carrying just its accumulated text"
632 );
633 }
634
635 const REDACTED_BLOB: &[u8] = b"\x00opaque-stream-ciphertext\xff";
636
637 #[tokio::test]
639 async fn redacted_reasoning_delta_reaches_the_consumer() {
640 let mut events = vec![reasoning_redacted_delta(0, REDACTED_BLOB), block_stop(0)];
641 events.extend(terminal());
642
643 let drained = drain(events).await;
644
645 assert!(drained.errors.is_empty(), "errors: {:?}", drained.errors);
646 assert_eq!(
647 drained
648 .reasoning
649 .iter()
650 .flat_map(|reasoning| reasoning.content.iter())
651 .cloned()
652 .collect::<Vec<_>>(),
653 vec![ReasoningContent::Redacted {
654 data: BASE64_STANDARD.encode(REDACTED_BLOB),
655 }]
656 );
657 assert!(drained.reached_terminal);
658 }
659
660 #[tokio::test]
664 async fn redacted_reasoning_is_a_sibling_of_the_open_thinking_block() {
665 let mut events = vec![
666 reasoning_text_delta(0, "visible thinking"),
667 reasoning_signature_delta(0, "sig_1"),
668 reasoning_redacted_delta(0, REDACTED_BLOB),
669 block_stop(0),
670 ];
671 events.extend(terminal());
672
673 let drained = drain(events).await;
674
675 assert!(drained.errors.is_empty(), "errors: {:?}", drained.errors);
676 let content: Vec<ReasoningContent> = drained
677 .reasoning
678 .iter()
679 .flat_map(|reasoning| reasoning.content.iter())
680 .cloned()
681 .collect();
682 assert_eq!(
683 content,
684 vec![
685 ReasoningContent::Text {
686 text: "visible thinking".to_string(),
687 signature: Some("sig_1".to_string()),
688 },
689 ReasoningContent::Redacted {
690 data: BASE64_STANDARD.encode(REDACTED_BLOB),
691 },
692 ]
693 );
694 assert!(drained.reached_terminal);
695 }
696
697 #[tokio::test]
700 async fn non_tool_use_content_block_start_is_skipped_not_failed() {
701 let mut events = vec![
702 aws_bedrock::ConverseStreamOutput::ContentBlockStart(
703 aws_bedrock::ContentBlockStartEvent::builder()
704 .content_block_index(0)
705 .start(aws_bedrock::ContentBlockStart::ToolResult(
706 aws_bedrock::ToolResultBlockStart::builder()
707 .tool_use_id("tool_1")
708 .build()
709 .expect("tool result start should build"),
710 ))
711 .build()
712 .expect("content block start should build"),
713 ),
714 block_stop(0),
715 ];
716 events.extend(terminal());
717
718 let drained = drain(events).await;
719
720 assert!(
721 drained.errors.is_empty(),
722 "an unmodeled ContentBlockStart must not fail the stream: {:?}",
723 drained.errors
724 );
725 assert!(
726 drained.reached_terminal,
727 "the stream must still reach its terminal record"
728 );
729 }
730
731 #[test]
732 fn test_bedrock_usage_creation() {
733 let usage = TokenUsage {
734 input_tokens: 100,
735 output_tokens: 50,
736 total_tokens: 150,
737 cache_read_input_tokens: None,
738 cache_write_input_tokens: None,
739 };
740
741 assert_eq!(usage.input_tokens, 100);
742 assert_eq!(usage.output_tokens, 50);
743 assert_eq!(usage.total_tokens, 150);
744 }
745
746 #[test]
747 fn test_bedrock_streaming_response_with_usage() {
748 let response = BedrockStreamingResponse {
749 usage: Some(TokenUsage {
750 input_tokens: 200,
751 output_tokens: 75,
752 total_tokens: 275,
753 cache_read_input_tokens: Some(40),
754 cache_write_input_tokens: Some(10),
755 }),
756 stop_reason: None,
757 provider_request_id: None,
758 };
759
760 assert_eq!(
761 rig_core::completion::Usage::from(&response),
762 rig_core::completion::Usage {
763 input_tokens: 200,
764 output_tokens: 75,
765 total_tokens: 275,
766 cached_input_tokens: 40,
767 cache_creation_input_tokens: 10,
768 tool_use_prompt_tokens: 0,
769 reasoning_tokens: 0,
770 }
771 );
772 }
773
774 #[test]
775 fn test_bedrock_streaming_response_without_usage() {
776 let response = BedrockStreamingResponse {
777 usage: None,
778 stop_reason: None,
779 provider_request_id: None,
780 };
781
782 assert_eq!(
785 rig_core::completion::Usage::from(&response),
786 rig_core::completion::Usage::new()
787 );
788 assert!(!rig_core::completion::Usage::from(&response).has_values());
789 }
790
791 #[test]
792 fn test_streaming_response_normalizes_usage() {
793 let response = BedrockStreamingResponse {
794 usage: Some(TokenUsage {
795 input_tokens: 448,
796 output_tokens: 68,
797 total_tokens: 516,
798 cache_read_input_tokens: Some(80),
799 cache_write_input_tokens: Some(20),
800 }),
801 stop_reason: None,
802 provider_request_id: None,
803 };
804
805 assert_eq!(
807 rig_core::completion::Usage::from(&response),
808 rig_core::completion::Usage {
809 input_tokens: 448,
810 output_tokens: 68,
811 total_tokens: 516,
812 cached_input_tokens: 80,
813 cache_creation_input_tokens: 20,
814 tool_use_prompt_tokens: 0,
815 reasoning_tokens: 0,
816 }
817 );
818 }
819
820 #[test]
821 fn test_bedrock_usage_serde() {
822 let usage = TokenUsage {
823 input_tokens: 100,
824 output_tokens: 50,
825 total_tokens: 150,
826 cache_read_input_tokens: Some(25),
827 cache_write_input_tokens: Some(5),
828 };
829
830 let json = serde_json::to_string(&usage).expect("Should serialize");
832 assert!(json.contains("\"input_tokens\":100"));
833 assert!(json.contains("\"output_tokens\":50"));
834 assert!(json.contains("\"total_tokens\":150"));
835
836 let deserialized: TokenUsage = serde_json::from_str(&json).expect("Should deserialize");
838 assert_eq!(deserialized.input_tokens, usage.input_tokens);
839 assert_eq!(deserialized.output_tokens, usage.output_tokens);
840 assert_eq!(deserialized.total_tokens, usage.total_tokens);
841 assert_eq!(
842 deserialized.cache_read_input_tokens,
843 usage.cache_read_input_tokens
844 );
845 assert_eq!(
846 deserialized.cache_write_input_tokens,
847 usage.cache_write_input_tokens
848 );
849 }
850
851 #[test]
852 fn test_bedrock_streaming_response_serde() {
853 let response = BedrockStreamingResponse {
854 usage: Some(TokenUsage {
855 input_tokens: 200,
856 output_tokens: 75,
857 total_tokens: 275,
858 cache_read_input_tokens: Some(30),
859 cache_write_input_tokens: Some(15),
860 }),
861 stop_reason: None,
862 provider_request_id: None,
863 };
864
865 let json = serde_json::to_string(&response).expect("Should serialize");
867 assert!(json.contains("\"input_tokens\":200"));
868
869 let deserialized: BedrockStreamingResponse =
871 serde_json::from_str(&json).expect("Should deserialize");
872 assert!(deserialized.usage.is_some());
873 let usage = deserialized.usage.unwrap();
874 assert_eq!(usage.input_tokens, 200);
875 assert_eq!(usage.output_tokens, 75);
876 assert_eq!(usage.total_tokens, 275);
877 assert_eq!(usage.cache_read_input_tokens, Some(30));
878 assert_eq!(usage.cache_write_input_tokens, Some(15));
879 }
880
881 #[tokio::test]
885 async fn signed_thinking_block_closes_with_its_signature() {
886 let mut events = vec![
887 reasoning_text_delta(0, "I am "),
888 reasoning_text_delta(0, "thinking"),
889 reasoning_signature_delta(0, "sig-abc"),
890 block_stop(0),
891 ];
892 events.extend(terminal());
893
894 let drained = drain(events).await;
895
896 assert!(drained.errors.is_empty(), "errors: {:?}", drained.errors);
897 assert_eq!(
898 drained
899 .reasoning
900 .iter()
901 .flat_map(|reasoning| reasoning.content.iter())
902 .cloned()
903 .collect::<Vec<_>>(),
904 vec![ReasoningContent::Text {
905 text: "I am thinking".to_string(),
906 signature: Some("sig-abc".to_string()),
907 }]
908 );
909 }
910
911 #[tokio::test]
917 async fn signature_only_thinking_block_still_reaches_the_consumer() {
918 let mut events = vec![reasoning_signature_delta(0, "sig-only"), block_stop(0)];
919 events.extend(terminal());
920
921 let drained = drain(events).await;
922
923 assert!(drained.errors.is_empty(), "errors: {:?}", drained.errors);
924 assert_eq!(
925 drained
926 .reasoning
927 .iter()
928 .flat_map(|reasoning| reasoning.content.iter())
929 .cloned()
930 .collect::<Vec<_>>(),
931 vec![ReasoningContent::Text {
932 text: String::new(),
933 signature: Some("sig-only".to_string()),
934 }]
935 );
936 }
937
938 #[tokio::test]
942 async fn wholly_empty_thinking_block_emits_nothing() {
943 let mut events = vec![reasoning_text_delta(0, ""), block_stop(0)];
944 events.extend(terminal());
945
946 let drained = drain(events).await;
947
948 assert!(drained.errors.is_empty(), "errors: {:?}", drained.errors);
949 assert!(drained.reasoning.is_empty());
950 assert!(drained.reached_terminal);
951 }
952
953 fn tool_start_event(index: i32, id: &str, name: &str) -> aws_bedrock::ConverseStreamOutput {
954 aws_bedrock::ConverseStreamOutput::ContentBlockStart(
955 aws_bedrock::ContentBlockStartEvent::builder()
956 .content_block_index(index)
957 .start(aws_bedrock::ContentBlockStart::ToolUse(
958 aws_bedrock::ToolUseBlockStart::builder()
959 .tool_use_id(id)
960 .name(name)
961 .build()
962 .expect("tool use start should build"),
963 ))
964 .build()
965 .expect("content block start should build"),
966 )
967 }
968
969 fn tool_delta_event(index: i32, input: &str) -> aws_bedrock::ConverseStreamOutput {
970 aws_bedrock::ConverseStreamOutput::ContentBlockDelta(
971 aws_bedrock::ContentBlockDeltaEvent::builder()
972 .content_block_index(index)
973 .delta(aws_bedrock::ContentBlockDelta::ToolUse(
974 aws_bedrock::ToolUseBlockDelta::builder()
975 .input(input)
976 .build()
977 .expect("tool use delta should build"),
978 ))
979 .build()
980 .expect("content block delta should build"),
981 )
982 }
983
984 fn text_delta_event(index: i32, text: &str) -> aws_bedrock::ConverseStreamOutput {
985 aws_bedrock::ConverseStreamOutput::ContentBlockDelta(
986 aws_bedrock::ContentBlockDeltaEvent::builder()
987 .content_block_index(index)
988 .delta(aws_bedrock::ContentBlockDelta::Text(text.to_string()))
989 .build()
990 .expect("content block delta should build"),
991 )
992 }
993
994 fn block_stop_event(index: i32) -> aws_bedrock::ConverseStreamOutput {
995 aws_bedrock::ConverseStreamOutput::ContentBlockStop(
996 aws_bedrock::ContentBlockStopEvent::builder()
997 .content_block_index(index)
998 .build()
999 .expect("content block stop should build"),
1000 )
1001 }
1002
1003 fn message_stop_event(reason: aws_bedrock::StopReason) -> aws_bedrock::ConverseStreamOutput {
1004 aws_bedrock::ConverseStreamOutput::MessageStop(
1005 aws_bedrock::MessageStopEvent::builder()
1006 .stop_reason(reason)
1007 .build()
1008 .expect("message stop should build"),
1009 )
1010 }
1011
1012 fn run_events(
1015 events: Vec<aws_bedrock::ConverseStreamOutput>,
1016 ) -> (
1017 Vec<Result<RawStreamingChoice<BedrockStreamingResponse>, CompletionError>>,
1018 StreamState,
1019 ) {
1020 let mut state = StreamState::default();
1021 let mut items = Vec::new();
1022 for event in events {
1023 items.extend(process_event(&mut state, event));
1024 }
1025 (items, state)
1026 }
1027
1028 async fn assembled(
1034 items: Vec<Result<RawStreamingChoice<BedrockStreamingResponse>, CompletionError>>,
1035 ) -> (Vec<rig_core::message::ToolCall>, Vec<CompletionError>) {
1036 use futures::StreamExt;
1037 let raw: rig_core::streaming::RawStreamingResult<BedrockStreamingResponse> =
1038 Box::pin(futures::stream::iter(items));
1039 let mut stream =
1040 StreamingCompletionResponse::stream(PROVIDER_NAME, normalize_bedrock_stream(raw));
1041 let mut calls = Vec::new();
1042 let mut errors = Vec::new();
1043 while let Some(item) = stream.next().await {
1044 match item {
1045 Ok(rig_core::streaming::StreamedAssistantContent::ToolCall {
1046 tool_call, ..
1047 }) => calls.push(tool_call),
1048 Err(err) => errors.push(err),
1049 Ok(_) => {}
1050 }
1051 }
1052 (calls, errors)
1053 }
1054
1055 #[tokio::test]
1056 async fn parallel_tool_calls_all_emitted_with_tool_use_terminal() {
1057 let (items, state) = run_events(vec![
1060 tool_start_event(0, "call_a", "get_weather"),
1061 tool_delta_event(0, "{\"location\":"),
1062 tool_delta_event(0, "\"Paris\"}"),
1063 block_stop_event(0),
1064 tool_start_event(1, "call_b", "get_time"),
1065 tool_delta_event(1, "{\"zone\":\"UTC\"}"),
1066 block_stop_event(1),
1067 message_stop_event(aws_bedrock::StopReason::ToolUse),
1068 ]);
1069
1070 assert!(items.iter().all(|item| item.is_ok()));
1071 assert_eq!(state.final_stop_reason, Some(StopReason::ToolUse));
1073 assert_eq!(
1074 map_stop_reason(&StopReason::ToolUse),
1075 rig_core::completion::FinishReason::ToolCalls
1076 );
1077
1078 let (calls, errors) = assembled(items).await;
1079 assert!(errors.is_empty());
1080 assert_eq!(calls.len(), 2, "both parallel tool calls must be emitted");
1081 let first = calls.first().expect("first call");
1082 assert_eq!(first.id, "call_a");
1083 assert_eq!(first.function.name, "get_weather");
1084 assert_eq!(
1085 first.function.arguments,
1086 serde_json::json!({"location": "Paris"})
1087 );
1088 let second = calls.get(1).expect("second call");
1089 assert_eq!(second.id, "call_b");
1090 assert_eq!(second.function.name, "get_time");
1091 assert_eq!(
1092 second.function.arguments,
1093 serde_json::json!({"zone": "UTC"})
1094 );
1095 }
1096
1097 #[tokio::test]
1098 async fn tool_call_flushes_at_content_block_stop() {
1099 let (items, state) = run_events(vec![
1101 tool_start_event(0, "call_a", "get_weather"),
1102 tool_delta_event(0, "{}"),
1103 block_stop_event(0),
1104 ]);
1105
1106 assert!(state.tool_calls.is_empty(), "state must be cleared at stop");
1107 let (calls, errors) = assembled(items).await;
1108 assert!(errors.is_empty());
1109 assert_eq!(calls.len(), 1);
1110 }
1111
1112 #[tokio::test]
1113 async fn message_stop_flushes_stragglers_missing_a_block_stop() {
1114 let (items, _state) = run_events(vec![
1117 tool_start_event(0, "call_a", "get_weather"),
1118 tool_delta_event(0, "{\"location\":\"Paris\"}"),
1119 tool_start_event(1, "call_b", "get_time"),
1120 tool_delta_event(1, "{\"zone\":\"UTC\"}"),
1121 message_stop_event(aws_bedrock::StopReason::ToolUse),
1122 ]);
1123
1124 let (calls, errors) = assembled(items).await;
1125 assert!(errors.is_empty());
1126 assert_eq!(calls.len(), 2);
1127 assert_eq!(calls.first().expect("first call").id, "call_a");
1128 assert_eq!(calls.get(1).expect("second call").id, "call_b");
1129 }
1130
1131 #[tokio::test]
1132 async fn text_after_closed_tool_block_is_delivered() {
1133 let (items, _state) = run_events(vec![
1136 tool_start_event(0, "call_a", "get_weather"),
1137 tool_delta_event(0, "{}"),
1138 block_stop_event(0),
1139 text_delta_event(1, "Checking the weather now."),
1140 block_stop_event(1),
1141 message_stop_event(aws_bedrock::StopReason::EndTurn),
1142 ]);
1143
1144 let texts: Vec<&str> = items
1145 .iter()
1146 .filter_map(|item| match item {
1147 Ok(RawStreamingChoice::Message(text)) => Some(text.as_str()),
1148 _ => None,
1149 })
1150 .collect();
1151 assert_eq!(texts, vec!["Checking the weather now."]);
1152 let (calls, errors) = assembled(items).await;
1153 assert!(errors.is_empty());
1154 assert_eq!(calls.len(), 1);
1155 }
1156
1157 #[tokio::test]
1158 async fn malformed_tool_json_surfaces_an_error_item() {
1159 let (items, _state) = run_events(vec![
1162 tool_start_event(0, "call_a", "get_weather"),
1163 tool_delta_event(0, "{\"location\": not-json"),
1164 block_stop_event(0),
1165 message_stop_event(aws_bedrock::StopReason::ToolUse),
1166 ]);
1167
1168 let (calls, errors) = assembled(items).await;
1169 assert!(calls.is_empty());
1170 assert!(
1171 errors.iter().any(|err| matches!(
1172 err,
1173 CompletionError::ResponseError(msg) if msg.contains("get_weather")
1174 )),
1175 "malformed tool JSON must yield an error item"
1176 );
1177 }
1178
1179 #[tokio::test]
1180 async fn max_tokens_stop_drops_in_flight_tool_block_without_deltas() {
1181 let (items, state) = run_events(vec![
1186 tool_start_event(0, "call_a", "get_weather"),
1187 message_stop_event(aws_bedrock::StopReason::MaxTokens),
1188 ]);
1189
1190 assert!(
1191 items.iter().all(|item| item.is_ok()),
1192 "truncation must not surface as an error item"
1193 );
1194 assert_eq!(state.final_stop_reason, Some(StopReason::MaxTokens));
1195 assert_eq!(
1196 map_stop_reason(&StopReason::MaxTokens),
1197 rig_core::completion::FinishReason::Length
1198 );
1199 assert!(state.tool_calls.is_empty(), "state must be cleared at stop");
1200 let (calls, errors) = assembled(items).await;
1201 assert!(calls.is_empty());
1202 assert!(errors.is_empty(), "truncation must not surface as an error");
1203 }
1204
1205 #[tokio::test]
1206 async fn max_tokens_stop_drops_in_flight_tool_block_with_partial_json() {
1207 let (items, state) = run_events(vec![
1210 tool_start_event(0, "call_a", "get_weather"),
1211 tool_delta_event(0, "{\"location\":\"Par"),
1212 message_stop_event(aws_bedrock::StopReason::MaxTokens),
1213 ]);
1214
1215 assert!(
1216 items.iter().all(|item| item.is_ok()),
1217 "a truncated partial-JSON block must not yield an error item"
1218 );
1219 assert_eq!(state.final_stop_reason, Some(StopReason::MaxTokens));
1220 assert!(state.tool_calls.is_empty(), "state must be cleared at stop");
1221 let (calls, errors) = assembled(items).await;
1222 assert!(calls.is_empty());
1223 assert!(errors.is_empty(), "no spurious Err from the partial block");
1224 }
1225
1226 #[tokio::test]
1227 async fn empty_tool_input_becomes_empty_object() {
1228 let (items, _state) = run_events(vec![
1230 tool_start_event(0, "call_a", "ping"),
1231 block_stop_event(0),
1232 message_stop_event(aws_bedrock::StopReason::ToolUse),
1233 ]);
1234
1235 let (calls, errors) = assembled(items).await;
1236 assert!(errors.is_empty());
1237 assert_eq!(calls.len(), 1);
1238 assert_eq!(
1239 calls.first().expect("call").function.arguments,
1240 serde_json::json!({})
1241 );
1242 }
1243
1244 fn metadata_event_with_usage(input: i32, output: i32) -> aws_bedrock::ConverseStreamOutput {
1247 aws_bedrock::ConverseStreamOutput::Metadata(
1248 aws_bedrock::ConverseStreamMetadataEvent::builder()
1249 .usage(
1250 aws_bedrock::TokenUsage::builder()
1251 .input_tokens(input)
1252 .output_tokens(output)
1253 .total_tokens(input + output)
1254 .build()
1255 .expect("token usage should build"),
1256 )
1257 .build(),
1258 )
1259 }
1260
1261 async fn normalized_terminal(
1264 items: Vec<Result<RawStreamingChoice<BedrockStreamingResponse>, CompletionError>>,
1265 ) -> rig_core::streaming::StreamFinal {
1266 let raw: rig_core::streaming::RawStreamingResult<BedrockStreamingResponse> =
1267 Box::pin(futures::stream::iter(items));
1268 let mut stream =
1269 StreamingCompletionResponse::stream(PROVIDER_NAME, normalize_bedrock_stream(raw));
1270 while let Some(item) = stream.next().await {
1271 item.expect("stream item");
1272 }
1273 stream
1274 .response
1275 .expect("the stream must end with a terminal record")
1276 }
1277
1278 #[tokio::test]
1283 async fn stream_from_events_terminal_carries_raw() {
1284 let mut stream = stream_from_events(futures::stream::iter(
1285 vec![
1286 text_delta_event(0, "hi"),
1287 block_stop(0),
1288 message_stop_event(aws_bedrock::StopReason::EndTurn),
1289 metadata_event_with_usage(3, 1),
1290 ]
1291 .into_iter()
1292 .map(Ok),
1293 ));
1294 while let Some(item) = stream.next().await {
1295 item.expect("stream item");
1296 }
1297 let terminal = stream.response.expect("terminal record");
1298
1299 let raw = &terminal.raw;
1300 let typed: BedrockStreamingResponse =
1301 serde_json::from_value(raw.clone()).expect("raw must deserialize");
1302 assert_eq!(typed.stop_reason, Some(StopReason::EndTurn));
1303 assert_eq!(terminal.usage.total_tokens, 4);
1304 }
1305
1306 #[tokio::test]
1313 async fn terminal_raw_round_trips_into_the_terminal_type() {
1314 let (items, _) = run_events(vec![
1315 text_delta_event(0, "hi"),
1316 block_stop(0),
1317 message_stop_event(aws_bedrock::StopReason::EndTurn),
1318 metadata_event_with_usage(3, 1),
1319 ]);
1320 let terminal = normalized_terminal(items).await;
1321
1322 let raw = &terminal.raw;
1323 let typed: BedrockStreamingResponse =
1324 serde_json::from_value(raw.clone()).expect("raw must deserialize");
1325 assert_eq!(
1326 serde_json::to_value(&typed).expect("re-serialize"),
1327 *raw,
1328 "the capture must be exactly what the terminal type serializes to"
1329 );
1330 assert_eq!(typed.stop_reason, Some(StopReason::EndTurn));
1331 assert_eq!(
1332 typed.usage.as_ref().map(|usage| usage.total_tokens),
1333 Some(4)
1334 );
1335
1336 let renormalized =
1339 normalized_terminal(vec![Ok(RawStreamingChoice::FinalResponse(typed))]).await;
1340 assert_eq!(terminal.identity(), renormalized.identity());
1341 assert_eq!(terminal.finish_reason, renormalized.finish_reason);
1342 assert_eq!(terminal.model, renormalized.model);
1343 assert_eq!(terminal.usage, renormalized.usage);
1344 assert_eq!(
1345 terminal.finish_reason,
1346 Some(rig_core::completion::FinishReason::Stop)
1347 );
1348 }
1349}
1350
1351#[cfg(test)]
1352mod response_identity_tests {
1353 use super::*;
1354
1355 #[test]
1360 fn streaming_terminal_request_id_normalizes_into_stream_final() {
1361 let response = BedrockStreamingResponse {
1362 usage: None,
1363 stop_reason: Some(StopReason::EndTurn),
1364 provider_request_id: Some("aws-req-1".to_string()),
1365 };
1366
1367 let usage = (&response).into();
1368 let terminal = rig_core::streaming::StreamFinal::new(PROVIDER_NAME, usage)
1369 .with_optional_provider_request_id(response.provider_request_id.clone())
1370 .with_optional_finish_reason(response.stop_reason.as_ref().map(map_stop_reason));
1371 assert_eq!(terminal.provider_request_id.as_deref(), Some("aws-req-1"));
1372
1373 let without = BedrockStreamingResponse {
1375 usage: None,
1376 stop_reason: None,
1377 provider_request_id: None,
1378 };
1379 let terminal = rig_core::streaming::StreamFinal::new(PROVIDER_NAME, (&without).into())
1380 .with_optional_provider_request_id(without.provider_request_id.clone());
1381 assert_eq!(terminal.provider_request_id, None);
1382 }
1383}