Skip to main content

rig_bedrock/
streaming.rs

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    /// Bedrock's own `stopReason` from the terminal `MessageStop` event, when
29    /// the stream reported one.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub stop_reason: Option<StopReason>,
32    /// The AWS request id from the converse-stream response's metadata
33    /// (`x-amzn-RequestId`) — not part of any stream event; stamped by
34    /// `raw_stream` from the SDK operation output, matching the unary
35    /// surface's semantics. `None` when the SDK reported none.
36    #[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 carried by this block's `signature` delta — the only
53    /// adapter-side state, because the wire delivers it out of band from the
54    /// thinking text. Thinking TEXT accumulates in the shared accumulator via
55    /// `ReasoningDelta`s; no restatement buffer exists.
56    signature: Option<String>,
57}
58
59/// Minted block identity for a Converse `contentBlockIndex`.
60///
61/// The wire index is `i32`; the minted index space is unsigned. The offset
62/// map is injective over the whole `i32` domain, so even a (spec-violating)
63/// negative index yields a distinct, well-formed minted identity instead of
64/// a rendering the identity machinery could disagree about — the mint stays
65/// total instead of trusting the wire.
66fn 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
71/// Close the open thinking block for `content_block_index`.
72///
73/// The end carries no restatement — the shared accumulator already holds every
74/// `ReasoningDelta` this block streamed, so restating the text would supersede
75/// the accumulation with a second copy of itself — only the signature, which
76/// the wire never restates. Adaptive-thinking blocks can even be
77/// signature-only (a `Signature` delta with no non-empty `Text` delta), and
78/// dropping that signature makes the next turn fail with
79/// `messages.N.content.0.thinking.signature: Field required` on replay. A
80/// wholly empty block still lands nowhere: a payload-less end creates no part.
81fn reasoning_end(
82    state: ReasoningState,
83    content_block_index: i32,
84) -> RawStreamingChoice<BedrockStreamingResponse> {
85    RawStreamingChoice::ReasoningEnd {
86        // Bedrock has no reasoning item id; the block's `contentBlockIndex`
87        // is stable across its deltas and its close.
88        id: block_id(content_block_index),
89        reasoning: None,
90        signature: state.signature,
91        // Both call sites close on a frame the wire actually sent — its own
92        // `contentBlockStop`, or the redacted sibling delta that ends the
93        // plaintext block — so the completed block reaches the consumer.
94        wire_sent: true,
95    }
96}
97
98/// Accumulated per-stream state for [`process_event`].
99///
100/// In-flight tool calls are keyed by Bedrock's own `contentBlockIndex` via
101/// the shared [`ToolCallBridge`]: the Converse stream indexes every content
102/// block, and a message may open several tool-use blocks before it stops, so
103/// a single "current" slot would let a later block silently overwrite an
104/// earlier one. Fragment assembly, internal-id minting, and finalize policy
105/// live in the shared accumulator (`PartsAccumulator::tool_input_*`); the
106/// bridge keeps only the index → identity mapping (and the name, for the
107/// dropped-block warning).
108#[derive(Default)]
109struct StreamState {
110    tool_calls: ToolCallBridge<i32>,
111    current_reasoning: Option<ReasoningState>,
112    final_stop_reason: Option<StopReason>,
113}
114
115/// A static, log-safe label for a stop reason: known variants map to their
116/// wire spelling, `Unknown` collapses to `"other"` so its carried wire
117/// string (potentially model output) never reaches a log line.
118fn 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
130/// Handle one Converse stream event, returning the items to yield in order.
131///
132/// Kept as a plain function over [`StreamState`] so the event bookkeeping can
133/// be unit-tested without an AWS event receiver.
134fn 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                        // Emit the delta so UI can show progress; the shared
152                        // accumulator assembles the fragments.
153                        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                        // Marks the block open so its stop emits an end; the
162                        // text itself belongs to the shared accumulator.
163                        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                                // Derive identity from `contentBlockIndex`
171                                // (no wire id on Converse reasoning blocks).
172                                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                        // Opaque provider state the safety classifier
185                        // encrypted. It is not part of any plaintext thinking
186                        // block, so close an open one first: sharing the
187                        // block index would otherwise make the redacted block
188                        // *replace* the delta-built thinking part instead of
189                        // landing beside it as a sibling.
190                        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                            // Same minted block identity as the sibling
196                            // reasoning paths, so provenance and boundary
197                            // semantics stay uniform.
198                            id: block_id(event.content_block_index),
199                            provider_id: None,
200                            content: ReasoningContent::Redacted {
201                                // The wire carries raw bytes; rig's canonical
202                                // reasoning content is a string, so the blob
203                                // travels base64-encoded and decodes back on
204                                // the way out.
205                                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                    // The wire always supplies a tool-use id here; the shared
232                    // bridge fixes it as the assembly key (and would mint one
233                    // in the reserved namespace if the wire ever omitted it).
234                    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                // `ContentBlockStart` is a union: `toolUse` is the only
245                // variant modeled today, and a future one is not a stream
246                // failure. Failing the turn here contradicted every sibling
247                // arm (which warn and skip) and the classify layer's
248                // Unknown-frame policy, and the message ("Stream is empty")
249                // described neither the frame nor the cause.
250                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            // A closed tool-use block is complete: finalize and emit it here,
264            // mirroring the reasoning close above, so every call in a
265            // multi-tool-call message reaches the consumer. The shared
266            // accumulator finalizes the assembled input: an empty accumulated
267            // input means a tool with no parameters, and malformed JSON
268            // surfaces as an error item (`UnparseableToolInput::Error`) rather
269            // than a silent drop, so the terminal can never report tool use
270            // whose calls the consumer never saw.
271            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            // Remember Bedrock's own terminal reason so the final
279            // record can report it; an unmapped SDK variant is kept
280            // verbatim rather than dropped.
281            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            // Tool calls normally flush at their ContentBlockStop; when the
289            // message genuinely stopped for tool use, drain any stragglers
290            // here defensively (in block order) so a stream that omits the
291            // stop event still delivers every call. Under any other stop
292            // reason (notably MaxTokens) an in-flight block is one the model
293            // never finished: drop it rather than fabricate a `{}`-args call
294            // or a spurious error item — truncation is signaled to the
295            // consumer by the mapped finish reason on the terminal record,
296            // which the Metadata path emits via `final_stop_reason`.
297            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                // Structural metadata only: tool names can be model-chosen
305                // (a hallucinated call's name is model output) and the
306                // `Unknown` variant carries a wire string, so neither may
307                // reach the WARN log. Known variants log a static label —
308                // unknown ones collapse to "other", never the wire value.
309                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            // Extract usage information from metadata; a missing usage still
322            // yields a terminal record so the stream ends with a FinalResponse.
323            let final_response = BedrockStreamingResponse {
324                // The mirror conversion is infallible for `TokenUsage`.
325                usage: metadata_event
326                    .usage
327                    .and_then(|usage| TokenUsage::try_from(usage).ok()),
328                stop_reason: state.final_stop_reason.clone(),
329                // Stamped by `raw_stream`; the adapter never sees the SDK
330                // operation output's metadata.
331                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        // The AWS SDK already deserialized the event-stream frame, so the
347        // byte-level decode step collapses: an event-stream decode failure
348        // surfaces as a receive error on the transport, and the only triage
349        // left here is the SDK's own unknown-variant signal on its
350        // non-exhaustive union.
351        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        // EOF without Bedrock's `Metadata` terminal is truncation: in-flight
372        // blocks drop and no terminal record may be synthesized.
373    }
374}
375
376/// Drive already-typed Converse stream events through the full shared
377/// pipeline — driver policy, canonical grammar, terminal normalization.
378///
379/// The events-first conformance seam: the adapter is a pure
380/// `(state, event) → events` function, so grammar scenarios feed SDK events
381/// directly with no AWS transport.
382pub 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    /// Open a stream whose terminal record stays Bedrock's own response type.
405    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        // Read the AWS request id off the operation output *before* the event
452        // stream is moved — `ConverseStreamOutput` implements the SDK
453        // `RequestId` trait on the whole output, not on stream events.
454        let provider_request_id =
455            aws_sdk_bedrockruntime::operation::RequestId::request_id(&response).map(str::to_string);
456
457        // Transport layer: SDK event-stream frames only — an event-stream
458        // decode/receive failure is a transport error; classification and
459        // policy live in the shared driver.
460        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        // Stamp the terminal record with the id captured above, mirroring the
475        // unary surface (`InternalConverseOutput::request_id`).
476        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    /// Open a stream normalized to rig's terminal record. Delegates to
490    /// [`CompletionModel::raw_stream`] — one request either way.
491    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    // ---- Event-seam helpers: no AWS transport, `stream_from_events` only ----
513
514    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    /// Ordinary extended-thinking shape through the SHARED driver: thinking
604    /// deltas, the block's whole-block close at `contentBlockStop`, then
605    /// visible text. The driver's boundary law must treat the same-key whole
606    /// block as a close — this exact stream used to abort every debug build
607    /// (sequence-law O1).
608    #[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    /// #2258 F2(a): the redacted delta used to hit `_ => {}` and vanish.
638    #[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    /// The redacted block must land BESIDE an open thinking block, not replace
661    /// it: both share `block-{index}`, so without draining the open state
662    /// first the accumulator would supersede the delta-built thinking part.
663    #[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    /// #2258 H5: a non-`ToolUse` `ContentBlockStart` used to fail the whole
698    /// stream with `ProviderError("Stream is empty")`.
699    #[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        // Zero-valued usage is rig's documented sentinel for "the provider
783        // reported no usage metrics".
784        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        // The streaming response normalizes into rig's usage record.
806        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        // Test serialization
831        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        // Test deserialization
837        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        // Test serialization
866        let json = serde_json::to_string(&response).expect("Should serialize");
867        assert!(json.contains("\"input_tokens\":200"));
868
869        // Test deserialization
870        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    /// A signed thinking block closes with its signature attached to the
882    /// text the shared accumulator assembled from the deltas — the exact
883    /// shape the next turn must replay to Bedrock.
884    #[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    /// Adaptive thinking on Bedrock can produce a `Signature` delta with no
912    /// non-empty `Text` delta. The signature is replay-required provider
913    /// state, so a signature-only block must still reach the consumer —
914    /// dropping it fails the next turn with
915    /// `messages.N.content.0.thinking.signature: Field required`.
916    #[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    /// A block that streamed nothing at all — an empty `Text` delta and no
939    /// signature — says nothing at its stop: the payload-less end must not
940    /// conjure an empty reasoning part.
941    #[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    /// Run a sequence of events through [`process_event`] with fresh state,
1013    /// returning every item the stream would yield, plus the final state.
1014    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    /// Drive the raw items through the same normalized pipeline the public
1029    /// stream uses (terminal mapping plus the shared accumulator), returning
1030    /// the completed tool calls and the in-band errors a consumer would see.
1031    /// Tool-call finalization happens in the accumulator, so assertions about
1032    /// completed calls and malformed-input errors belong at this level.
1033    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        // Two tool-use blocks in one message: both must survive, and the
1058        // latched stop reason must map to a tool-use terminal.
1059        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        // The terminal reports tool use with the calls actually delivered.
1072        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        // The call must not wait for MessageStop: closing the block emits it.
1100        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        // Defensive path: a stream that omits ContentBlockStop still delivers
1115        // every accumulated call at MessageStop, in block order.
1116        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        // A text block following a closed tool-use block used to be discarded
1134        // because the single tool slot was never cleared.
1135        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        // Malformed accumulated input must not be silently dropped while the
1160        // terminal still claims tool use: the consumer gets an error item.
1161        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        // A tool-use block cut off by MaxTokens before any input arrived must
1182        // produce neither a fabricated `{}`-args call nor an error item; the
1183        // truncation is signaled by the Length-mapping stop reason on the
1184        // terminal record.
1185        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        // Same, but with partial JSON accumulated: the malformed input must
1208        // not be parsed into a spurious Err at MessageStop.
1209        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        // A tool with no parameters streams no input deltas at all.
1229        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    /// Bedrock's terminal `Metadata` event carrying usage, so the stream ends
1245    /// with a fully populated `BedrockStreamingResponse`.
1246    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    /// Drive `items` through the normalized pipeline exactly as the
1262    /// `CompletionModel` seam does, returning the terminal.
1263    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    /// The events-first seam captures like the request-driven one: its
1279    /// terminal `raw` is the same `BedrockStreamingResponse` the model's
1280    /// `stream()` would attach, because both funnel through
1281    /// `normalize_bedrock_stream`.
1282    #[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    /// The load-bearing streaming capture property at the seam
1307    /// `CompletionModel::stream` routes through: the terminal's `raw` is
1308    /// Bedrock's own `BedrockStreamingResponse` — it deserializes back into
1309    /// that type and re-serializes identically — and re-normalizing that
1310    /// capture reproduces every normalized field. The Bedrock `stopReason`
1311    /// spelling is only readable off the capture.
1312    #[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        // Feeding the capture back through the same pipeline tells the same
1337        // story as the terminal the stream produced.
1338        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    /// Blocking/streaming parity (rig#2265): the streaming terminal's AWS
1356    /// request id — stamped from the SDK operation output, the same source
1357    /// the unary surface reads — normalizes into
1358    /// `StreamFinal.provider_request_id`.
1359    #[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        // And a response without one stays None — never an error.
1374        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}