Skip to main content

llm/providers/bedrock/
streaming.rs

1use aws_sdk_bedrockruntime::error::SdkError;
2use aws_sdk_bedrockruntime::primitives::event_stream::EventReceiver;
3use aws_sdk_bedrockruntime::types::error::ConverseStreamOutputError;
4use aws_sdk_bedrockruntime::types::{
5    ContentBlockDelta, ContentBlockStart, ConverseStreamOutput, StopReason as BedrockStopReason,
6    TokenUsage as BedrockTokenUsage,
7};
8use aws_smithy_types::event_stream::RawMessage;
9use futures::Stream;
10use std::collections::HashMap;
11use tracing::{debug, error, info, warn};
12
13use crate::{LlmError, LlmResponse, StopReason, TokenUsage, ToolCallRequest};
14
15impl From<&BedrockTokenUsage> for TokenUsage {
16    fn from(usage: &BedrockTokenUsage) -> Self {
17        TokenUsage {
18            input_tokens: u32::try_from(usage.input_tokens).unwrap_or(0),
19            output_tokens: u32::try_from(usage.output_tokens).unwrap_or(0),
20            cache_read_tokens: usage.cache_read_input_tokens().and_then(|v| u32::try_from(v).ok()),
21            cache_creation_tokens: usage.cache_write_input_tokens().and_then(|v| u32::try_from(v).ok()),
22            cache_reporting_exclusive: Some(true),
23            ..TokenUsage::default()
24        }
25    }
26}
27
28struct PendingToolCall {
29    id: String,
30    name: String,
31    args: String,
32}
33
34enum StreamEvent {
35    Emit(LlmResponse),
36    Stop(StopReason),
37    Skip,
38}
39
40pub fn process_bedrock_stream(
41    mut receiver: EventReceiver<ConverseStreamOutput, ConverseStreamOutputError>,
42) -> impl Stream<Item = crate::Result<LlmResponse>> + Send {
43    async_stream::stream! {
44        let message_id = uuid::Uuid::new_v4().to_string();
45        yield Ok(LlmResponse::Start { message_id });
46
47        let mut active_tool_calls: HashMap<i32, PendingToolCall> = HashMap::new();
48        let mut last_stop_reason: Option<StopReason> = None;
49
50        loop {
51            match receiver.recv().await {
52                Ok(Some(event)) => {
53                    match process_stream_event(&event, &mut active_tool_calls) {
54                        StreamEvent::Emit(resp) => yield Ok(resp),
55                        StreamEvent::Stop(sr) => last_stop_reason = Some(sr),
56                        StreamEvent::Skip => {}
57                    }
58                }
59                Ok(None) => {
60                    debug!("Bedrock stream ended (recv returned None)");
61                    break;
62                }
63                Err(e) => {
64                    error!("Bedrock stream recv error: {e}");
65                    yield Err(LlmError::from(e));
66                    break;
67                }
68            }
69        }
70
71        // Emit any remaining tool calls that weren't completed via ContentBlockStop
72        for (_index, tc) in active_tool_calls {
73            let tool_call = ToolCallRequest {
74                id: tc.id,
75                name: tc.name,
76                arguments: tc.args,
77            };
78            yield Ok(LlmResponse::ToolRequestComplete { tool_call });
79        }
80
81        yield Ok(LlmResponse::Done {
82            stop_reason: last_stop_reason,
83        });
84    }
85}
86
87fn process_stream_event(
88    event: &ConverseStreamOutput,
89    active_tool_calls: &mut HashMap<i32, PendingToolCall>,
90) -> StreamEvent {
91    match event {
92        ConverseStreamOutput::MessageStart(_) => {
93            info!("Bedrock message started");
94            StreamEvent::Skip
95        }
96        ConverseStreamOutput::ContentBlockStart(start_event) => {
97            handle_content_block_start(start_event, active_tool_calls)
98        }
99        ConverseStreamOutput::ContentBlockDelta(delta_event) => {
100            handle_content_block_delta(delta_event, active_tool_calls)
101        }
102        ConverseStreamOutput::ContentBlockStop(stop_event) => {
103            handle_content_block_stop(stop_event.content_block_index(), active_tool_calls)
104        }
105        ConverseStreamOutput::MessageStop(stop_event) => {
106            let stop_reason = map_bedrock_stop_reason(&stop_event.stop_reason);
107            info!("Bedrock message stopped: {stop_reason:?}");
108            StreamEvent::Stop(stop_reason)
109        }
110        ConverseStreamOutput::Metadata(metadata_event) => metadata_event
111            .usage()
112            .map_or(StreamEvent::Skip, |usage| StreamEvent::Emit(LlmResponse::Usage { tokens: usage.into() })),
113        other => {
114            warn!("Unhandled Bedrock stream event: {other:?}");
115            StreamEvent::Skip
116        }
117    }
118}
119
120fn handle_content_block_start(
121    event: &aws_sdk_bedrockruntime::types::ContentBlockStartEvent,
122    active_tool_calls: &mut HashMap<i32, PendingToolCall>,
123) -> StreamEvent {
124    let index = event.content_block_index();
125
126    if let Some(ContentBlockStart::ToolUse(tool_start)) = event.start() {
127        let id = tool_start.tool_use_id().to_string();
128        let name = tool_start.name().to_string();
129        debug!("Bedrock tool use started: {name} ({id})");
130        active_tool_calls.insert(index, PendingToolCall { id: id.clone(), name: name.clone(), args: String::new() });
131        StreamEvent::Emit(LlmResponse::ToolRequestStart { id, name })
132    } else {
133        debug!("Content block started at index {index}");
134        StreamEvent::Skip
135    }
136}
137
138fn handle_content_block_delta(
139    event: &aws_sdk_bedrockruntime::types::ContentBlockDeltaEvent,
140    active_tool_calls: &mut HashMap<i32, PendingToolCall>,
141) -> StreamEvent {
142    let index = event.content_block_index();
143
144    let Some(delta) = event.delta() else {
145        return StreamEvent::Skip;
146    };
147
148    match delta {
149        ContentBlockDelta::Text(text) if !text.is_empty() => {
150            StreamEvent::Emit(LlmResponse::Text { chunk: text.clone() })
151        }
152        ContentBlockDelta::ToolUse(tool_delta) => {
153            let input = tool_delta.input();
154            if input.is_empty() {
155                return StreamEvent::Skip;
156            }
157
158            if let Some(tc) = active_tool_calls.get_mut(&index) {
159                tc.args.push_str(input);
160                StreamEvent::Emit(LlmResponse::ToolRequestArg { id: tc.id.clone(), chunk: input.to_string() })
161            } else {
162                warn!("Received tool input delta for unknown content block index: {index}");
163                StreamEvent::Skip
164            }
165        }
166        ContentBlockDelta::ReasoningContent(reasoning) => {
167            if let Ok(text) = reasoning.as_text()
168                && !text.is_empty()
169            {
170                return StreamEvent::Emit(LlmResponse::Reasoning { chunk: text.clone() });
171            }
172            StreamEvent::Skip
173        }
174        _ => {
175            debug!("Unhandled content block delta type");
176            StreamEvent::Skip
177        }
178    }
179}
180
181fn handle_content_block_stop(index: i32, active_tool_calls: &mut HashMap<i32, PendingToolCall>) -> StreamEvent {
182    if let Some(tc) = active_tool_calls.remove(&index) {
183        let tool_call = ToolCallRequest { id: tc.id, name: tc.name, arguments: tc.args };
184        StreamEvent::Emit(LlmResponse::ToolRequestComplete { tool_call })
185    } else {
186        debug!("Content block stopped at index {index}");
187        StreamEvent::Skip
188    }
189}
190
191impl From<SdkError<ConverseStreamOutputError, RawMessage>> for LlmError {
192    fn from(e: SdkError<ConverseStreamOutputError, RawMessage>) -> Self {
193        let message = format!("Bedrock stream error: {e}");
194        match e {
195            SdkError::ServiceError(svc) => {
196                let inner = svc.err();
197                if inner.is_throttling_exception() {
198                    LlmError::RateLimited(message)
199                } else if inner.is_service_unavailable_exception()
200                    || inner.is_internal_server_exception()
201                    || inner.is_model_stream_error_exception()
202                {
203                    LlmError::StreamInterrupted(message)
204                } else {
205                    LlmError::ApiError(message)
206                }
207            }
208            _ => LlmError::StreamInterrupted(message),
209        }
210    }
211}
212
213fn map_bedrock_stop_reason(reason: &BedrockStopReason) -> StopReason {
214    match reason {
215        BedrockStopReason::EndTurn | BedrockStopReason::StopSequence => StopReason::EndTurn,
216        BedrockStopReason::ToolUse => StopReason::ToolCalls,
217        BedrockStopReason::MaxTokens | BedrockStopReason::ModelContextWindowExceeded => StopReason::Length,
218        BedrockStopReason::ContentFiltered | BedrockStopReason::GuardrailIntervened => StopReason::ContentFilter,
219        other => StopReason::Unknown(format!("{other:?}")),
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn test_map_stop_reason_end_turn() {
229        assert_eq!(map_bedrock_stop_reason(&BedrockStopReason::EndTurn), StopReason::EndTurn);
230    }
231
232    #[test]
233    fn test_map_stop_reason_stop_sequence() {
234        assert_eq!(map_bedrock_stop_reason(&BedrockStopReason::StopSequence), StopReason::EndTurn);
235    }
236
237    #[test]
238    fn test_map_stop_reason_tool_use() {
239        assert_eq!(map_bedrock_stop_reason(&BedrockStopReason::ToolUse), StopReason::ToolCalls);
240    }
241
242    #[test]
243    fn test_map_stop_reason_max_tokens() {
244        assert_eq!(map_bedrock_stop_reason(&BedrockStopReason::MaxTokens), StopReason::Length);
245    }
246
247    #[test]
248    fn test_map_stop_reason_context_window_exceeded() {
249        assert_eq!(map_bedrock_stop_reason(&BedrockStopReason::ModelContextWindowExceeded), StopReason::Length);
250    }
251
252    #[test]
253    fn test_map_stop_reason_content_filtered() {
254        assert_eq!(map_bedrock_stop_reason(&BedrockStopReason::ContentFiltered), StopReason::ContentFilter);
255    }
256
257    #[test]
258    fn test_map_stop_reason_guardrail() {
259        assert_eq!(map_bedrock_stop_reason(&BedrockStopReason::GuardrailIntervened), StopReason::ContentFilter);
260    }
261
262    #[test]
263    fn test_handle_content_block_start_tool_use() {
264        let mut active = HashMap::new();
265        let tool_start = aws_sdk_bedrockruntime::types::ToolUseBlockStart::builder()
266            .tool_use_id("tool_123")
267            .name("search")
268            .build()
269            .unwrap();
270
271        let event = aws_sdk_bedrockruntime::types::ContentBlockStartEvent::builder()
272            .content_block_index(0)
273            .start(ContentBlockStart::ToolUse(tool_start))
274            .build()
275            .unwrap();
276
277        let result = handle_content_block_start(&event, &mut active);
278        assert!(
279            matches!(&result, StreamEvent::Emit(LlmResponse::ToolRequestStart { id, name }) if id == "tool_123" && name == "search")
280        );
281        assert!(active.contains_key(&0));
282    }
283
284    #[test]
285    fn test_handle_content_block_delta_text() {
286        let mut active = HashMap::new();
287        let delta = aws_sdk_bedrockruntime::types::ContentBlockDeltaEvent::builder()
288            .content_block_index(0)
289            .delta(ContentBlockDelta::Text("Hello".to_string()))
290            .build()
291            .unwrap();
292
293        let result = handle_content_block_delta(&delta, &mut active);
294        assert!(matches!(&result, StreamEvent::Emit(LlmResponse::Text { chunk }) if chunk == "Hello"));
295    }
296
297    #[test]
298    fn test_handle_content_block_delta_tool_input() {
299        let mut active = HashMap::new();
300        active
301            .insert(0, PendingToolCall { id: "tool_123".to_string(), name: "search".to_string(), args: String::new() });
302
303        let tool_delta =
304            aws_sdk_bedrockruntime::types::ToolUseBlockDelta::builder().input(r#"{"query":"test"}"#).build().unwrap();
305
306        let delta = aws_sdk_bedrockruntime::types::ContentBlockDeltaEvent::builder()
307            .content_block_index(0)
308            .delta(ContentBlockDelta::ToolUse(tool_delta))
309            .build()
310            .unwrap();
311
312        let result = handle_content_block_delta(&delta, &mut active);
313        assert!(
314            matches!(&result, StreamEvent::Emit(LlmResponse::ToolRequestArg { id, chunk }) if id == "tool_123" && chunk == r#"{"query":"test"}"#)
315        );
316
317        // Verify accumulated args
318        assert_eq!(active.get(&0).unwrap().args, r#"{"query":"test"}"#);
319    }
320
321    #[test]
322    fn test_handle_content_block_stop_completes_tool() {
323        let mut active = HashMap::new();
324        active.insert(
325            0,
326            PendingToolCall {
327                id: "tool_123".to_string(),
328                name: "search".to_string(),
329                args: r#"{"query":"test"}"#.to_string(),
330            },
331        );
332
333        let result = handle_content_block_stop(0, &mut active);
334        assert!(matches!(&result, StreamEvent::Emit(LlmResponse::ToolRequestComplete { tool_call })
335            if tool_call.id == "tool_123"
336            && tool_call.name == "search"
337            && tool_call.arguments == r#"{"query":"test"}"#
338        ));
339        assert!(active.is_empty());
340    }
341
342    #[test]
343    fn test_handle_content_block_stop_no_tool() {
344        let mut active = HashMap::new();
345        let result = handle_content_block_stop(0, &mut active);
346        assert!(matches!(result, StreamEvent::Skip));
347    }
348
349    #[test]
350    fn test_metadata_event_emits_cache_read_and_creation() {
351        let usage = aws_sdk_bedrockruntime::types::TokenUsage::builder()
352            .input_tokens(100)
353            .output_tokens(50)
354            .total_tokens(150)
355            .cache_read_input_tokens(40)
356            .cache_write_input_tokens(20)
357            .build()
358            .unwrap();
359
360        let metadata = aws_sdk_bedrockruntime::types::ConverseStreamMetadataEvent::builder().usage(usage).build();
361
362        let event = ConverseStreamOutput::Metadata(metadata);
363        let mut active = HashMap::new();
364        let result = process_stream_event(&event, &mut active);
365
366        match result {
367            StreamEvent::Emit(LlmResponse::Usage { tokens: sample }) => {
368                assert_eq!(sample.input_tokens, 100);
369                assert_eq!(sample.output_tokens, 50);
370                assert_eq!(sample.cache_read_tokens, Some(40));
371                assert_eq!(sample.cache_creation_tokens, Some(20));
372                assert_eq!(sample.cache_reporting_exclusive, Some(true));
373            }
374            _ => panic!("expected Emit(Usage{{..}})"),
375        }
376    }
377
378    #[test]
379    fn test_metadata_event_without_cache_fields() {
380        let usage = aws_sdk_bedrockruntime::types::TokenUsage::builder()
381            .input_tokens(10)
382            .output_tokens(5)
383            .total_tokens(15)
384            .build()
385            .unwrap();
386
387        let metadata = aws_sdk_bedrockruntime::types::ConverseStreamMetadataEvent::builder().usage(usage).build();
388
389        let event = ConverseStreamOutput::Metadata(metadata);
390        let mut active = HashMap::new();
391        let result = process_stream_event(&event, &mut active);
392
393        match result {
394            StreamEvent::Emit(LlmResponse::Usage { tokens: sample }) => {
395                assert_eq!(sample.cache_read_tokens, None);
396                assert_eq!(sample.cache_creation_tokens, None);
397            }
398            _ => panic!("expected Emit(Usage{{..}})"),
399        }
400    }
401
402    #[test]
403    fn test_handle_content_block_delta_empty_text() {
404        let mut active = HashMap::new();
405        let delta = aws_sdk_bedrockruntime::types::ContentBlockDeltaEvent::builder()
406            .content_block_index(0)
407            .delta(ContentBlockDelta::Text(String::new()))
408            .build()
409            .unwrap();
410
411        let result = handle_content_block_delta(&delta, &mut active);
412        assert!(matches!(result, StreamEvent::Skip));
413    }
414}