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