1use aws_sdk_bedrockruntime::types as aws_bedrock;
2use base64::{Engine, prelude::BASE64_STANDARD};
3
4use rig_core::{
5 completion::CompletionError,
6 message::{AssistantContent, Text},
7};
8use serde::{Deserialize, Serialize};
9
10use crate::types::message::RigMessage;
11
12use super::{
13 converse_output::{
14 ContentBlock, InternalConverseOutput, ReasoningContentBlock, StopReason, TokenUsage,
15 },
16 json::AwsDocument,
17};
18use rig_core::completion::{self};
19use rig_core::telemetry::ProviderResponseExt;
20
21#[derive(Clone, Deserialize, Serialize)]
22pub struct AwsConverseOutput(pub InternalConverseOutput);
23
24pub(crate) fn normalize_usage(usage: &TokenUsage) -> completion::Usage {
27 completion::Usage {
28 input_tokens: usage.input_tokens as u64,
29 output_tokens: usage.output_tokens as u64,
30 total_tokens: usage.total_tokens as u64,
31 cached_input_tokens: usage.cache_read_input_tokens.unwrap_or_default() as u64,
32 cache_creation_input_tokens: usage.cache_write_input_tokens.unwrap_or_default() as u64,
33 tool_use_prompt_tokens: 0,
34 reasoning_tokens: 0,
35 }
36}
37
38impl ProviderResponseExt for AwsConverseOutput {
39 type Usage = completion::Usage;
40
41 fn get_response_id(&self) -> Option<String> {
42 None }
44
45 fn get_response_model_name(&self) -> Option<String> {
46 None }
48
49 fn get_text_response(&self) -> Option<String> {
50 let output = self.0.output.as_ref()?;
51 let message = output.as_message().ok()?;
52 let response = message
53 .content
54 .iter()
55 .filter_map(|block| match block {
56 ContentBlock::Text(text) => Some(text.clone()),
57 _ => None,
58 })
59 .collect::<Vec<_>>()
60 .join("\n");
61
62 if response.is_empty() {
63 None
64 } else {
65 Some(response)
66 }
67 }
68
69 fn get_usage(&self) -> Option<Self::Usage> {
70 self.0.usage().map(normalize_usage)
71 }
72}
73
74pub const PROVIDER_NAME: &str = "aws_bedrock";
76
77pub fn map_stop_reason(stop_reason: &StopReason) -> completion::FinishReason {
84 match stop_reason {
85 StopReason::EndTurn | StopReason::StopSequence => completion::FinishReason::Stop,
86 StopReason::MaxTokens => completion::FinishReason::Length,
87 StopReason::ToolUse => completion::FinishReason::ToolCalls,
88 StopReason::ContentFiltered | StopReason::GuardrailIntervened => {
89 completion::FinishReason::ContentFilter
90 }
91 StopReason::Unknown(value) => completion::FinishReason::Other(value.to_string()),
92 }
93}
94
95impl TryFrom<AwsConverseOutput> for completion::CompletionResponse {
96 type Error = CompletionError;
97
98 fn try_from(value: AwsConverseOutput) -> Result<Self, Self::Error> {
99 let message: RigMessage = value
100 .to_owned()
101 .0
102 .output
103 .ok_or(CompletionError::ProviderError(
104 "Model didn't return any output".into(),
105 ))?
106 .as_message()
107 .map_err(|_| {
108 CompletionError::ProviderError(
109 "Failed to extract message from converse output".into(),
110 )
111 })?
112 .to_owned()
113 .try_into()?;
114
115 let choice = match message.0 {
120 completion::Message::Assistant { content, .. } => Ok(content),
121 _ => Err(CompletionError::ResponseError(
122 "Converse output message was not an assistant message".to_owned(),
123 )),
124 }?;
125
126 let usage = value.0.usage().map(normalize_usage).unwrap_or_default();
127
128 let finish_reason = map_stop_reason(&value.0.stop_reason);
129
130 let provider_request_id = value.0.request_id().map(str::to_string);
134
135 Ok(
136 completion::CompletionResponse::new(choice, usage, PROVIDER_NAME)
137 .with_optional_provider_request_id(provider_request_id)
138 .with_finish_reason(finish_reason),
139 )
140 }
141}
142
143pub struct RigAssistantContent(pub AssistantContent);
144
145impl TryFrom<ContentBlock> for RigAssistantContent {
146 type Error = CompletionError;
147
148 fn try_from(value: ContentBlock) -> Result<Self, Self::Error> {
149 match value {
150 ContentBlock::Text(text) => {
151 Ok(RigAssistantContent(AssistantContent::Text(Text::new(text))))
152 }
153 ContentBlock::ToolUse(call) => Ok(RigAssistantContent(
154 completion::AssistantContent::tool_call(&call.tool_use_id, &call.name, call.input),
155 )),
156 ContentBlock::ReasoningContent(reasoning_block) => match reasoning_block {
157 ReasoningContentBlock::ReasoningText(reasoning_text) => Ok(RigAssistantContent(
158 AssistantContent::Reasoning(rig_core::message::Reasoning::new_with_signature(
159 &reasoning_text.text,
160 reasoning_text.signature,
161 )),
162 )),
163 ReasoningContentBlock::RedactedContent(blob) => {
170 Ok(RigAssistantContent(AssistantContent::Reasoning(
171 rig_core::message::Reasoning::redacted(BASE64_STANDARD.encode(blob.inner)),
172 )))
173 }
174 _ => Err(CompletionError::ProviderError(
175 "AWS Bedrock returned unsupported ReasoningContentBlock variant".into(),
176 )),
177 },
178 _ => Err(CompletionError::ProviderError(
179 "AWS Bedrock returned unsupported ContentBlock".into(),
180 )),
181 }
182 }
183}
184
185impl RigAssistantContent {
186 pub(crate) fn into_content_block(
193 self,
194 ) -> Result<Option<aws_bedrock::ContentBlock>, CompletionError> {
195 match self.0 {
196 AssistantContent::Text(text) => Ok(Some(aws_bedrock::ContentBlock::Text(text.text))),
197 AssistantContent::ToolCall(tool_call) => {
198 let tool_use_id = tool_call.wire_call_id().to_owned();
204 let doc: AwsDocument = tool_call.function.arguments.into();
205 Ok(Some(aws_bedrock::ContentBlock::ToolUse(
206 aws_bedrock::ToolUseBlock::builder()
207 .tool_use_id(tool_use_id)
208 .name(tool_call.function.name)
209 .input(doc.0)
210 .build()
211 .map_err(|e| CompletionError::ProviderError(e.to_string()))?,
212 )))
213 }
214 AssistantContent::Reasoning(mut reasoning) => {
215 let foreign = reasoning
229 .content
230 .iter()
231 .filter(|content| {
232 matches!(content, rig_core::message::ReasoningContent::Encrypted(_))
233 })
234 .count();
235 if foreign > 0 {
236 tracing::warn!(
237 dropped = foreign,
238 "dropping foreign encrypted reasoning payload(s); Bedrock cannot \
239 verify another provider's ciphertext"
240 );
241 reasoning.content.retain(|content| {
242 !matches!(content, rig_core::message::ReasoningContent::Encrypted(_))
243 });
244 if reasoning.content.is_empty() {
245 return Ok(None);
246 }
247 }
248
249 let redacted: Vec<&str> = reasoning
250 .content
251 .iter()
252 .filter_map(|content| match content {
253 rig_core::message::ReasoningContent::Redacted { data } => {
254 Some(data.as_str())
255 }
256 _ => None,
257 })
258 .collect();
259
260 if !redacted.is_empty() {
261 if redacted.len() != reasoning.content.len() {
262 tracing::warn!(
269 dropped = redacted.len(),
270 "dropping redacted reasoning payloads Bedrock cannot carry \
271 alongside reasoning text; replaying the text only"
272 );
273 reasoning.content.retain(|content| {
274 !matches!(
275 content,
276 rig_core::message::ReasoningContent::Redacted { .. }
277 )
278 });
279 } else {
280 if redacted.len() > 1 {
281 tracing::warn!(
285 dropped = redacted.len() - 1,
286 "dropping extra redacted reasoning payloads; Bedrock carries \
287 one redactedContent blob per block"
288 );
289 }
290
291 let data = redacted.first().copied().unwrap_or_default();
298 return match BASE64_STANDARD.decode(data) {
299 Ok(bytes) => Ok(Some(aws_bedrock::ContentBlock::ReasoningContent(
300 aws_bedrock::ReasoningContentBlock::RedactedContent(
301 aws_smithy_types::Blob::new(bytes),
302 ),
303 ))),
304 Err(error) => {
305 tracing::warn!(
306 %error,
307 "dropping redacted reasoning content that is not valid \
308 base64"
309 );
310 Ok(None)
311 }
312 };
313 }
314 }
315
316 let signed_text_count = reasoning
317 .content
318 .iter()
319 .filter(|content| {
320 matches!(
321 content,
322 rig_core::message::ReasoningContent::Text {
323 signature: Some(_),
324 ..
325 }
326 )
327 })
328 .count();
329 if signed_text_count > 1 {
330 return Err(CompletionError::ProviderError(
331 "AWS Bedrock does not support multiple signed reasoning text blocks"
332 .to_owned(),
333 ));
334 }
335 if signed_text_count == 1 && reasoning.content.len() > 1 {
336 return Err(CompletionError::ProviderError(
337 "AWS Bedrock requires a single signed reasoning text block without additional reasoning parts"
338 .to_owned(),
339 ));
340 }
341
342 let flattened_text = reasoning.display_text();
343 let has_signature = reasoning.first_signature().is_some();
344 if flattened_text.is_empty() && !has_signature {
350 return Err(CompletionError::ProviderError(
351 "AWS Bedrock reasoning conversion requires at least one text or summary block"
352 .to_owned(),
353 ));
354 }
355
356 let mut reasoning_block =
357 aws_bedrock::ReasoningTextBlock::builder().text(flattened_text);
358
359 if let Some(sig) = reasoning.first_signature().map(str::to_owned) {
360 reasoning_block = reasoning_block.signature(sig);
361 }
362
363 let reasoning_text_block = reasoning_block.build().map_err(|e| {
364 CompletionError::ProviderError(format!(
365 "Failed to build reasoning block: {}",
366 e
367 ))
368 })?;
369
370 Ok(Some(aws_bedrock::ContentBlock::ReasoningContent(
371 aws_bedrock::ReasoningContentBlock::ReasoningText(reasoning_text_block),
372 )))
373 }
374 AssistantContent::Image(_) => Err(CompletionError::ProviderError(
375 "AWS Bedrock does not support image content in assistant messages".to_owned(),
376 )),
377 }
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use crate::types::{
384 assistant_content::RigAssistantContent,
385 converse_output::{ContentBlock, InternalConverseOutput},
386 errors::TypeConversionError,
387 json::AwsDocument,
388 };
389
390 use super::AwsConverseOutput;
391 use aws_sdk_bedrockruntime::types as aws_bedrock;
392 use base64::{Engine as _, prelude::BASE64_STANDARD};
393 use rig_core::{
394 completion,
395 message::{AssistantContent, ReasoningContent},
396 telemetry::ProviderResponseExt,
397 };
398 use serde_json::json;
399
400 fn mirrored(block: aws_bedrock::ContentBlock) -> ContentBlock {
403 block.try_into().expect("the SDK block mirrors")
404 }
405
406 fn make_output(text: &str, usage: Option<aws_bedrock::TokenUsage>) -> AwsConverseOutput {
408 make_output_with_content(vec![aws_bedrock::ContentBlock::Text(text.into())], usage)
409 }
410
411 fn make_output_with_content(
412 content: Vec<aws_bedrock::ContentBlock>,
413 usage: Option<aws_bedrock::TokenUsage>,
414 ) -> AwsConverseOutput {
415 let message = aws_bedrock::Message::builder()
416 .role(aws_bedrock::ConversationRole::Assistant)
417 .set_content(Some(content))
418 .build()
419 .unwrap();
420 let mut builder = aws_sdk_bedrockruntime::operation::converse::ConverseOutput::builder()
421 .output(aws_bedrock::ConverseOutput::Message(message))
422 .stop_reason(aws_bedrock::StopReason::EndTurn);
423 if let Some(u) = usage {
424 builder = builder.usage(u);
425 }
426 let internal: InternalConverseOutput = builder.build().unwrap().try_into().unwrap();
427 AwsConverseOutput(internal)
428 }
429
430 fn make_usage(input: i32, output: i32, total: i32) -> aws_bedrock::TokenUsage {
431 aws_bedrock::TokenUsage::builder()
432 .input_tokens(input)
433 .output_tokens(output)
434 .total_tokens(total)
435 .build()
436 .unwrap()
437 }
438
439 #[test]
440 fn provider_response_ext_get_text_response() {
441 let out = make_output("hello world", None);
442 assert_eq!(out.get_text_response(), Some("hello world".to_string()));
443 }
444
445 #[test]
446 fn provider_response_ext_response_id_is_none() {
447 let out = make_output("x", None);
448 assert!(out.get_response_id().is_none());
449 assert!(out.get_response_model_name().is_none());
450 }
451
452 #[test]
453 fn provider_response_ext_get_usage_with_tokens() {
454 let out = make_output("x", Some(make_usage(100, 50, 150)));
455 let usage = out.get_usage().unwrap();
456 assert_eq!(usage.input_tokens, 100);
457 assert_eq!(usage.output_tokens, 50);
458 assert_eq!(usage.total_tokens, 150);
459 }
460
461 #[test]
462 fn provider_response_ext_get_usage_none_when_missing() {
463 let out = make_output("x", None);
464 assert!(out.get_usage().is_none());
465 }
466
467 #[test]
468 fn get_token_usage_delegates_to_provider_response_ext() {
469 let out = make_output("x", Some(make_usage(10, 20, 30)));
470 assert_eq!(
471 out.get_usage().unwrap_or_default(),
472 completion::Usage {
473 input_tokens: 10,
474 output_tokens: 20,
475 total_tokens: 30,
476 ..completion::Usage::new()
477 }
478 );
479 }
480
481 #[test]
482 fn get_token_usage_zero_when_no_usage() {
483 let out = make_output("x", None);
484 assert_eq!(
487 out.get_usage().unwrap_or_default(),
488 completion::Usage::new()
489 );
490 assert!(!out.get_usage().unwrap_or_default().has_values());
491 }
492
493 #[test]
494 fn aws_converse_output_to_completion_response() {
495 let message = aws_bedrock::Message::builder()
496 .role(aws_bedrock::ConversationRole::Assistant)
497 .content(aws_bedrock::ContentBlock::Text("txt".into()))
498 .build()
499 .unwrap();
500 let output = aws_bedrock::ConverseOutput::Message(message);
501 let converse_output =
502 aws_sdk_bedrockruntime::operation::converse::ConverseOutput::builder()
503 .output(output)
504 .stop_reason(aws_bedrock::StopReason::EndTurn)
505 .build()
506 .unwrap();
507 let converse_output: Result<InternalConverseOutput, TypeConversionError> =
508 converse_output.try_into();
509 assert!(converse_output.is_ok());
510 let converse_output = converse_output.unwrap();
511 let completion: Result<completion::CompletionResponse, _> =
512 AwsConverseOutput(converse_output).try_into();
513 assert!(completion.is_ok());
514 let completion = completion.unwrap();
515 assert_eq!(
516 completion.choice,
517 vec![AssistantContent::Text("txt".into())]
518 );
519 }
520
521 #[test]
522 fn aws_converse_output_preserves_parallel_tool_calls_in_completion_response() {
523 let content = vec![
524 aws_bedrock::ContentBlock::Text("preface".into()),
525 aws_bedrock::ContentBlock::ToolUse(
526 aws_bedrock::ToolUseBlock::builder()
527 .tool_use_id("call_1")
528 .name("add")
529 .input(AwsDocument::from(json!({"x": 1, "y": 2})).0)
530 .build()
531 .unwrap(),
532 ),
533 aws_bedrock::ContentBlock::ToolUse(
534 aws_bedrock::ToolUseBlock::builder()
535 .tool_use_id("call_2")
536 .name("subtract")
537 .input(AwsDocument::from(json!({"x": 4, "y": 3})).0)
538 .build()
539 .unwrap(),
540 ),
541 ];
542
543 let completion: completion::CompletionResponse = make_output_with_content(content, None)
544 .try_into()
545 .expect("conversion should succeed");
546
547 let choice: Vec<_> = completion.choice.into_iter().collect();
548 assert_eq!(choice.len(), 3);
549 assert_eq!(choice[0], AssistantContent::Text("preface".into()));
550
551 let AssistantContent::ToolCall(first_tool) = &choice[1] else {
552 panic!("expected first tool call");
553 };
554 assert_eq!(first_tool.id, "call_1");
555 assert_eq!(first_tool.function.name, "add");
556 assert_eq!(first_tool.function.arguments, json!({"x": 1, "y": 2}));
557
558 let AssistantContent::ToolCall(second_tool) = &choice[2] else {
559 panic!("expected second tool call");
560 };
561 assert_eq!(second_tool.id, "call_2");
562 assert_eq!(second_tool.function.name, "subtract");
563 assert_eq!(second_tool.function.arguments, json!({"x": 4, "y": 3}));
564 }
565
566 #[test]
567 fn tool_use_echo_prefers_provider_call_id_like_the_result_leg() {
568 let tool_call = rig_core::message::ToolCall::new(
573 rig_core::message::ToolCallId::new("minted-handle").unwrap(),
574 rig_core::message::ToolFunction {
575 name: "add".into(),
576 arguments: json!({"x": 1}),
577 },
578 )
579 .with_provider(rig_core::message::ProviderCallId::new("call_abc").unwrap());
580
581 let block = RigAssistantContent(AssistantContent::ToolCall(tool_call))
582 .into_content_block()
583 .unwrap()
584 .expect("tool calls never degrade away");
585 let aws_bedrock::ContentBlock::ToolUse(tool_use) = block else {
586 panic!("expected a toolUse block");
587 };
588 assert_eq!(tool_use.tool_use_id(), "call_abc");
589 }
590
591 #[test]
592 fn tool_use_echo_falls_back_to_minted_handle_without_provider_id() {
593 let tool_call = rig_core::message::ToolCall::new(
594 rig_core::message::ToolCallId::new("minted-handle").unwrap(),
595 rig_core::message::ToolFunction {
596 name: "add".into(),
597 arguments: json!({"x": 1}),
598 },
599 );
600
601 let block = RigAssistantContent(AssistantContent::ToolCall(tool_call))
602 .into_content_block()
603 .unwrap()
604 .expect("tool calls never degrade away");
605 let aws_bedrock::ContentBlock::ToolUse(tool_use) = block else {
606 panic!("expected a toolUse block");
607 };
608 assert_eq!(tool_use.tool_use_id(), "minted-handle");
609 }
610
611 #[test]
612 fn aws_content_block_to_assistant_content() {
613 let content_block = mirrored(aws_bedrock::ContentBlock::Text("text".into()));
614 let rig_assistant_content: Result<RigAssistantContent, _> = content_block.try_into();
615 assert!(rig_assistant_content.is_ok());
616 assert_eq!(
617 rig_assistant_content.unwrap().0,
618 AssistantContent::Text("text".into())
619 );
620 }
621
622 #[test]
623 fn aws_reasoning_content_to_assistant_content_without_signature() {
624 let reasoning_text_block = aws_bedrock::ReasoningTextBlock::builder()
626 .text("This is my reasoning")
627 .build()
628 .unwrap();
629
630 let content_block = mirrored(aws_bedrock::ContentBlock::ReasoningContent(
631 aws_bedrock::ReasoningContentBlock::ReasoningText(reasoning_text_block),
632 ));
633
634 let rig_assistant_content: Result<RigAssistantContent, _> = content_block.try_into();
635 assert!(rig_assistant_content.is_ok());
636
637 match rig_assistant_content.unwrap().0 {
638 AssistantContent::Reasoning(reasoning) => {
639 assert_eq!(reasoning.first_text(), Some("This is my reasoning"));
640 assert_eq!(reasoning.first_signature(), None);
641 assert!(matches!(
642 reasoning.content.first(),
643 Some(ReasoningContent::Text { text, signature: None }) if text == "This is my reasoning"
644 ));
645 }
646 _ => panic!("Expected AssistantContent::Reasoning"),
647 }
648 }
649
650 #[test]
651 fn aws_reasoning_content_to_assistant_content_with_signature() {
652 let reasoning_text_block = aws_bedrock::ReasoningTextBlock::builder()
654 .text("This is my reasoning with signature")
655 .signature("test_signature_123")
656 .build()
657 .unwrap();
658
659 let content_block = mirrored(aws_bedrock::ContentBlock::ReasoningContent(
660 aws_bedrock::ReasoningContentBlock::ReasoningText(reasoning_text_block),
661 ));
662
663 let rig_assistant_content: Result<RigAssistantContent, _> = content_block.try_into();
664 assert!(rig_assistant_content.is_ok());
665
666 match rig_assistant_content.unwrap().0 {
667 AssistantContent::Reasoning(reasoning) => {
668 assert_eq!(
669 reasoning.first_text(),
670 Some("This is my reasoning with signature")
671 );
672 assert_eq!(reasoning.first_signature(), Some("test_signature_123"));
673 assert!(matches!(
674 reasoning.content.first(),
675 Some(ReasoningContent::Text { text, signature: Some(sig) })
676 if text == "This is my reasoning with signature" && sig == "test_signature_123"
677 ));
678 }
679 _ => panic!("Expected AssistantContent::Reasoning"),
680 }
681 }
682
683 #[test]
684 fn rig_reasoning_to_aws_content_block_without_signature() {
685 let reasoning = rig_core::message::Reasoning::new("My reasoning content");
687 let rig_content = RigAssistantContent(AssistantContent::Reasoning(reasoning));
688
689 let aws_content_block = rig_content
690 .into_content_block()
691 .expect("conversion should succeed")
692 .expect("the item converts to a block");
693
694 match aws_content_block {
695 aws_bedrock::ContentBlock::ReasoningContent(
696 aws_bedrock::ReasoningContentBlock::ReasoningText(reasoning_text),
697 ) => {
698 assert_eq!(reasoning_text.text, "My reasoning content");
699 assert_eq!(reasoning_text.signature, None);
700 }
701 _ => panic!("Expected ContentBlock::ReasoningContent"),
702 }
703 }
704
705 #[test]
706 fn rig_reasoning_to_aws_content_block_with_signature() {
707 let reasoning = rig_core::message::Reasoning::new_with_signature(
709 "My reasoning content",
710 Some("sig_abc_123".to_string()),
711 );
712 let rig_content = RigAssistantContent(AssistantContent::Reasoning(reasoning));
713
714 let aws_content_block = rig_content
715 .into_content_block()
716 .expect("conversion should succeed")
717 .expect("the item converts to a block");
718
719 match aws_content_block {
720 aws_bedrock::ContentBlock::ReasoningContent(
721 aws_bedrock::ReasoningContentBlock::ReasoningText(reasoning_text),
722 ) => {
723 assert_eq!(reasoning_text.text, "My reasoning content");
724 assert_eq!(reasoning_text.signature, Some("sig_abc_123".to_string()));
725 }
726 _ => panic!("Expected ContentBlock::ReasoningContent"),
727 }
728 }
729
730 #[test]
731 fn rig_reasoning_with_multiple_strings_to_aws_content_block() {
732 let reasoning = rig_core::message::Reasoning::multi(vec![
734 "First part".to_string(),
735 " Second part".to_string(),
736 " Third part".to_string(),
737 ]);
738
739 let rig_content = RigAssistantContent(AssistantContent::Reasoning(reasoning));
740
741 let aws_content_block = rig_content
742 .into_content_block()
743 .expect("conversion should succeed")
744 .expect("the item converts to a block");
745
746 match aws_content_block {
747 aws_bedrock::ContentBlock::ReasoningContent(
748 aws_bedrock::ReasoningContentBlock::ReasoningText(reasoning_text),
749 ) => {
750 assert_eq!(reasoning_text.text, "First part\n Second part\n Third part");
751 }
752 _ => panic!("Expected ContentBlock::ReasoningContent"),
753 }
754 }
755
756 #[test]
757 fn rig_reasoning_with_empty_text_and_signature_is_converted() {
758 let reasoning = rig_core::message::Reasoning::new_with_signature(
763 "",
764 Some("sig_empty_text".to_string()),
765 );
766 let rig_content = RigAssistantContent(AssistantContent::Reasoning(reasoning));
767
768 let aws_content_block = rig_content
769 .into_content_block()
770 .expect("conversion should succeed")
771 .expect("the item converts to a block");
772
773 match aws_content_block {
774 aws_bedrock::ContentBlock::ReasoningContent(
775 aws_bedrock::ReasoningContentBlock::ReasoningText(reasoning_text),
776 ) => {
777 assert_eq!(reasoning_text.text, "");
778 assert_eq!(reasoning_text.signature, Some("sig_empty_text".to_string()));
779 }
780 _ => panic!("Expected ContentBlock::ReasoningContent"),
781 }
782 }
783
784 #[test]
785 fn rig_reasoning_with_empty_text_and_no_signature_returns_error() {
786 let reasoning = rig_core::message::Reasoning::new_with_signature("", None);
787 let rig_content = RigAssistantContent(AssistantContent::Reasoning(reasoning));
788
789 let aws_content_block = rig_content.into_content_block();
790 assert!(matches!(
791 aws_content_block,
792 Err(completion::CompletionError::ProviderError(message))
793 if message.contains("at least one text or summary block")
794 ));
795 }
796
797 #[test]
798 fn rig_reasoning_with_multiple_signed_text_blocks_returns_error() {
799 let mut reasoning =
800 rig_core::message::Reasoning::new_with_signature("part one", Some("sig_1".to_string()));
801 reasoning.content.push(ReasoningContent::Text {
802 text: "part two".to_string(),
803 signature: Some("sig_2".to_string()),
804 });
805 let rig_content = RigAssistantContent(AssistantContent::Reasoning(reasoning));
806
807 let aws_content_block = rig_content.into_content_block();
808 assert!(matches!(
809 aws_content_block,
810 Err(completion::CompletionError::ProviderError(message))
811 if message.contains("multiple signed reasoning text blocks")
812 ));
813 }
814
815 const REDACTED_BLOB: &[u8] = b"\x00\x01opaque-ciphertext\xff";
818
819 #[test]
820 fn aws_redacted_reasoning_content_becomes_redacted_reasoning_not_an_error() {
821 let content_block = mirrored(aws_bedrock::ContentBlock::ReasoningContent(
824 aws_bedrock::ReasoningContentBlock::RedactedContent(aws_smithy_types::Blob::new(
825 REDACTED_BLOB.to_vec(),
826 )),
827 ));
828
829 let rig_content: RigAssistantContent = content_block
830 .try_into()
831 .expect("redacted reasoning must convert, not error");
832
833 match rig_content.0 {
834 AssistantContent::Reasoning(reasoning) => assert_eq!(
835 reasoning.content,
836 vec![ReasoningContent::Redacted {
837 data: BASE64_STANDARD.encode(REDACTED_BLOB),
838 }]
839 ),
840 other => panic!("Expected redacted reasoning, got {other:?}"),
841 }
842 }
843
844 #[test]
845 fn rig_redacted_reasoning_replays_as_redacted_content_never_as_plaintext() {
846 let reasoning =
850 rig_core::message::Reasoning::redacted(BASE64_STANDARD.encode(REDACTED_BLOB));
851 let rig_content = RigAssistantContent(AssistantContent::Reasoning(reasoning));
852
853 let aws_content_block = rig_content
854 .into_content_block()
855 .expect("redacted reasoning should convert outbound")
856 .expect("a native redacted blob replays");
857
858 match aws_content_block {
859 aws_bedrock::ContentBlock::ReasoningContent(
860 aws_bedrock::ReasoningContentBlock::RedactedContent(blob),
861 ) => assert_eq!(blob.as_ref(), REDACTED_BLOB),
862 other => panic!("Expected RedactedContent, got {other:?}"),
863 }
864 }
865
866 #[test]
867 fn redacted_reasoning_round_trips_byte_for_byte() {
868 let inbound = mirrored(aws_bedrock::ContentBlock::ReasoningContent(
869 aws_bedrock::ReasoningContentBlock::RedactedContent(aws_smithy_types::Blob::new(
870 REDACTED_BLOB.to_vec(),
871 )),
872 ));
873
874 let rig_content: RigAssistantContent =
875 inbound.try_into().expect("inbound conversion should work");
876 let outbound = rig_content
877 .into_content_block()
878 .expect("outbound conversion should work")
879 .expect("a native redacted blob replays");
880
881 match outbound {
882 aws_bedrock::ContentBlock::ReasoningContent(
883 aws_bedrock::ReasoningContentBlock::RedactedContent(blob),
884 ) => assert_eq!(blob.as_ref(), REDACTED_BLOB),
885 other => panic!("Expected RedactedContent, got {other:?}"),
886 }
887 }
888
889 #[test]
895 fn foreign_encrypted_reasoning_is_dropped_never_shipped_as_redacted() {
896 let reasoning =
897 rig_core::message::Reasoning::encrypted(BASE64_STANDARD.encode(REDACTED_BLOB));
898 let rig_content = RigAssistantContent(AssistantContent::Reasoning(reasoning));
899
900 let converted = rig_content
901 .into_content_block()
902 .expect("foreign ciphertext must degrade, not fail the request");
903 assert!(
904 converted.is_none(),
905 "another provider's ciphertext must not reach Converse: {converted:?}"
906 );
907 }
908
909 #[test]
914 fn opaque_reasoning_mixed_with_text_drops_the_opaque_part_not_the_request() {
915 let mut reasoning = rig_core::message::Reasoning::new("visible thinking");
916 reasoning.content.push(ReasoningContent::Redacted {
917 data: BASE64_STANDARD.encode(REDACTED_BLOB),
918 });
919 let rig_content = RigAssistantContent(AssistantContent::Reasoning(reasoning));
920
921 let aws_content_block = rig_content
922 .into_content_block()
923 .expect("mixed block must degrade")
924 .expect("the representable text replays");
925 match aws_content_block {
926 aws_bedrock::ContentBlock::ReasoningContent(
927 aws_bedrock::ReasoningContentBlock::ReasoningText(text),
928 ) => {
929 assert_eq!(text.text(), "visible thinking");
930 }
931 other => panic!("Expected ReasoningText, got {other:?}"),
932 }
933 }
934
935 #[test]
940 fn responses_summary_plus_encrypted_reasoning_replays_as_text() {
941 let mut reasoning = rig_core::message::Reasoning::new("");
942 reasoning.content.clear();
943 reasoning
944 .content
945 .push(ReasoningContent::Summary("the summary".to_owned()));
946 reasoning
947 .content
948 .push(ReasoningContent::Encrypted("enc-opaque-blob".to_owned()));
949 let rig_content = RigAssistantContent(AssistantContent::Reasoning(reasoning));
950
951 let aws_content_block = rig_content
952 .into_content_block()
953 .expect("the Responses encrypted shape must degrade, not fail the request")
954 .expect("the summary text replays");
955 match aws_content_block {
956 aws_bedrock::ContentBlock::ReasoningContent(
957 aws_bedrock::ReasoningContentBlock::ReasoningText(text),
958 ) => {
959 assert_eq!(text.text(), "the summary");
960 assert!(
961 !text.text().contains("enc-opaque-blob"),
962 "ciphertext must never flatten into the plaintext body"
963 );
964 }
965 other => panic!("Expected ReasoningText, got {other:?}"),
966 }
967 }
968
969 #[test]
972 fn multiple_opaque_reasoning_payloads_keep_the_first() {
973 let mut reasoning =
974 rig_core::message::Reasoning::redacted(BASE64_STANDARD.encode(REDACTED_BLOB));
975 reasoning.content.push(ReasoningContent::Redacted {
976 data: BASE64_STANDARD.encode(b"second"),
977 });
978 let rig_content = RigAssistantContent(AssistantContent::Reasoning(reasoning));
979
980 let aws_content_block = rig_content
981 .into_content_block()
982 .expect("multiple opaque payloads must degrade")
983 .expect("the first native blob replays");
984 match aws_content_block {
985 aws_bedrock::ContentBlock::ReasoningContent(
986 aws_bedrock::ReasoningContentBlock::RedactedContent(blob),
987 ) => {
988 assert_eq!(blob.as_ref(), REDACTED_BLOB);
989 }
990 other => panic!("Expected RedactedContent, got {other:?}"),
991 }
992 }
993
994 #[test]
999 fn foreign_encrypted_reasoning_never_fails_the_request() {
1000 let reasoning = rig_core::message::Reasoning::encrypted("gAAAA-non_base64-token_");
1001 let rig_content = RigAssistantContent(AssistantContent::Reasoning(reasoning));
1002 let converted = rig_content
1003 .into_content_block()
1004 .expect("foreign ciphertext must degrade, not fail the request");
1005 assert!(converted.is_none());
1006 }
1007
1008 #[test]
1013 fn non_base64_redacted_reasoning_is_dropped_rather_than_sent_corrupt() {
1014 let reasoning = rig_core::message::Reasoning::redacted("not*valid*base64");
1015 let rig_content = RigAssistantContent(AssistantContent::Reasoning(reasoning));
1016
1017 let converted = rig_content
1018 .into_content_block()
1019 .expect("an un-decodable blob must degrade, not fail the request");
1020 assert!(converted.is_none());
1021 }
1022
1023 #[test]
1035 fn aws_converse_output_round_trips_through_serde_json_value() {
1036 let mut builder = aws_sdk_bedrockruntime::operation::converse::ConverseOutput::builder()
1037 .output(aws_bedrock::ConverseOutput::Message(
1038 aws_bedrock::Message::builder()
1039 .role(aws_bedrock::ConversationRole::Assistant)
1040 .content(aws_bedrock::ContentBlock::Text("hello".into()))
1041 .build()
1042 .expect("message should build"),
1043 ))
1044 .stop_reason(aws_bedrock::StopReason::EndTurn)
1045 .usage(make_usage(3, 1, 4))
1046 .metrics(
1047 aws_bedrock::ConverseMetrics::builder()
1048 .latency_ms(42)
1049 .build()
1050 .expect("metrics should build"),
1051 );
1052 builder = builder.additional_model_response_fields(aws_smithy_types::Document::Object(
1053 std::collections::HashMap::from([(
1054 "stop_sequence".to_string(),
1055 aws_smithy_types::Document::String("alpha".to_string()),
1056 )]),
1057 ));
1058 let internal: InternalConverseOutput = builder
1059 .build()
1060 .expect("converse output should build")
1061 .try_into()
1062 .expect("the SDK output mirrors");
1063 let raw = AwsConverseOutput(internal);
1064
1065 let value = serde_json::to_value(&raw).expect("serialize");
1066 assert_eq!(value["metrics"]["latency_ms"], 42);
1067 assert_eq!(
1068 value["additional_model_response_fields"]["stop_sequence"],
1069 "alpha"
1070 );
1071 assert!(value.get("trace").is_none(), "SDK-typed extras are skipped");
1072
1073 let back: AwsConverseOutput = serde_json::from_value(value.clone()).expect("deserialize");
1074 assert_eq!(
1075 serde_json::to_value(&back).expect("re-serialize"),
1076 value,
1077 "the capture must read back into AwsConverseOutput and re-serialize identically"
1078 );
1079
1080 let original: completion::CompletionResponse = raw.try_into().expect("original converts");
1081 let restored: completion::CompletionResponse = back.try_into().expect("restored converts");
1082 assert_eq!(restored.identity(), original.identity());
1083 assert_eq!(restored.finish_reason(), original.finish_reason());
1084 assert_eq!(restored.usage, original.usage);
1085 assert_eq!(restored.choice, original.choice);
1086 assert_eq!(
1087 restored.finish_reason(),
1088 Some(completion::FinishReason::Stop)
1089 );
1090 }
1091}