Skip to main content

rig_bedrock/types/
assistant_content.rs

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
24/// Normalize Bedrock token counts into rig's usage record. Shared by the
25/// unary response path and the streaming terminal record.
26pub(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 // Bedrock Converse API doesn't return a response ID
43    }
44
45    fn get_response_model_name(&self) -> Option<String> {
46        None // Bedrock doesn't echo model name in response
47    }
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
74/// Stable descriptor name reported on normalized Bedrock responses.
75pub const PROVIDER_NAME: &str = "aws_bedrock";
76
77/// Map Bedrock's `stopReason` onto rig's normalized vocabulary.
78///
79/// `StopSequence` is a natural stop, not a truncation — the model emitted a
80/// configured stop string and finished. Guardrail intervention is a content
81/// filter by another name. Anything the SDK surfaced as unknown is carried
82/// verbatim.
83pub 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        // This arm rejects a *role* mismatch, not an empty choice — the
116        // empty-converted-content case is rejected upstream in the message
117        // conversion — so it carries its own diagnostic rather than the
118        // shared empty-response wording.
119        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        // Bedrock's transport request id comes from the AWS SDK's response
131        // metadata (`x-amzn-RequestId`), captured when the SDK output was
132        // converted into `InternalConverseOutput`.
133        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                // Content the safety classifier encrypted. It is normal model
164                // output, not a protocol violation: erroring here failed the
165                // whole response over a block the streaming path and the
166                // direct-Anthropic adapter both carry. The blob is bytes and
167                // rig's canonical reasoning content is a string, so it travels
168                // base64-encoded and decodes on the way back out.
169                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    /// Convert one assistant content item for the Converse request.
187    ///
188    /// `Ok(None)` means the item degrades away entirely: opaque reasoning
189    /// Bedrock cannot carry — another provider's ciphertext, or a redacted
190    /// blob that no longer decodes — drops with a warning instead of
191    /// failing the request.
192    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                // Both Converse legs must agree on `toolUseId`: the result
199                // leg (user_content.rs) sends the provider-issued call id
200                // when one exists, so the assistant echo does too — a bare
201                // minted handle here would orphan the paired toolResult
202                // whenever the two diverge.
203                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                // Opaque payloads are ciphertext, not prose — and their
216                // provenance is in the variant. `Redacted` is
217                // Bedrock-native: this file's own inbound legs base64-encode
218                // Converse's `redactedContent` bytes, so only it may decode
219                // back onto the wire. `Encrypted` NEVER originates here — it
220                // is OpenAI Responses `encrypted_content`, OpenRouter
221                // `reasoning.encrypted`, or Anthropic ciphertext, stored
222                // verbatim (not base64) — and Bedrock can neither verify nor
223                // use another provider's ciphertext. Shipping it as
224                // Bedrock's own `redactedContent` hands Converse a body its
225                // models never wrote, and its token shapes routinely fail
226                // strict base64, which used to fail the whole request.
227                // Degrade, don't fail: drop what Converse cannot carry.
228                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                        // A mixed block cannot be represented on Converse
263                        // (one block is either `reasoningText` or
264                        // `redactedContent`). Cross-provider replay must
265                        // degrade, not fail the whole request locally: drop
266                        // the un-representable opaque part(s), keep the
267                        // representable text.
268                        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                            // All-redacted with several payloads: keep the
282                            // first, drop the rest — same degrade-don't-fail
283                            // policy.
284                            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                        // Round-trips the encoding the inbound legs apply:
292                        // the wire carries bytes, rig's canonical content is
293                        // a string. A blob that no longer decodes cannot be
294                        // replayed — degrade like the mixed case rather than
295                        // failing the request over history Bedrock will not
296                        // miss.
297                        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                // Adaptive thinking on Bedrock can emit a reasoning block whose
345                // plaintext body is empty but with a real cryptographic
346                // signature attached. The signature is what Anthropic uses to
347                // verify tool_use round-trips, so we must preserve it. Only
348                // reject when there's neither text nor signature to send.
349                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    /// The inbound path reads the mirror, but what Bedrock sends is the SDK
401    /// block, so the tests still start there and mirror it first.
402    fn mirrored(block: aws_bedrock::ContentBlock) -> ContentBlock {
403        block.try_into().expect("the SDK block mirrors")
404    }
405
406    /// Helper: build an AwsConverseOutput with text content and optional usage.
407    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        // Zero-valued usage is rig's documented sentinel for "the provider
485        // reported no usage metrics".
486        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        // Both Converse legs must send the same toolUseId. The result leg
569        // (user_content.rs) sends provider-else-handle; a diverged history
570        // (e.g. JSON-restored with independent id/provider fields) must not
571        // orphan the pair.
572        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        // Test conversion from AWS ReasoningContent to Rig AssistantContent without signature
625        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        // Test conversion from AWS ReasoningContent to Rig AssistantContent with signature
653        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        // Test conversion from Rig Reasoning to AWS ContentBlock without signature
686        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        // Test conversion from Rig Reasoning to AWS ContentBlock with signature
708        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        // Test that multiple reasoning strings are joined correctly
733        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        // Adaptive thinking on Bedrock can emit a reasoning block whose
759        // plaintext body is empty but with a real cryptographic signature
760        // attached. Verify we forward this as a `ReasoningTextBlock` with
761        // empty text + signature instead of rejecting it.
762        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    // ---- Redacted (safety-encrypted) reasoning: #2258 F2 ----
816
817    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        // Previously this failed the WHOLE response with "unsupported
822        // ReasoningContentBlock variant".
823        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        // The bug this pins: `display_text()` folds `Redacted` into the
847        // flattened text, so the blob used to be replayed as an unsigned
848        // `reasoningText` body.
849        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    /// `Encrypted` never originates on this wire — it is another
890    /// provider's ciphertext (OpenAI Responses `encrypted_content`,
891    /// OpenRouter `reasoning.encrypted`, Anthropic). It must never ship as
892    /// Bedrock's own `redactedContent`, even when it happens to decode:
893    /// the block degrades away entirely.
894    #[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    /// A mixed block degrades: the un-representable opaque part drops (with
910    /// a warning), the text replays — the request must not fail locally.
911    /// Never as flattened plaintext: the ciphertext must not reach the
912    /// `reasoningText` body.
913    #[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    /// The exact shape the OpenAI Responses path builds when
936    /// `encrypted_content` is requested: `[Summary, Encrypted]`. Replaying
937    /// that history to Bedrock must degrade to the summary text, never fail
938    /// the whole request (#2258 B3).
939    #[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    /// All-opaque with several payloads keeps the first and drops the rest
970    /// (Converse carries one `redactedContent` blob per block).
971    #[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    /// An all-encrypted block whose token is unpadded/URL-safe — the shape
995    /// OpenAI and OpenRouter actually store (verbatim, never
996    /// base64-standard) — must not fail the whole request locally, and
997    /// must never reach the decode at all.
998    #[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    /// A native redacted blob that no longer decodes cannot be replayed:
1009    /// it degrades away (with a warning) rather than shipping corrupt
1010    /// bytes OR failing the request — consistent with the mixed-block
1011    /// policy stated above.
1012    #[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    /// The load-bearing property behind `CompletionResponse::raw` for Bedrock:
1024    /// the captured value is `serde_json::to_value(&AwsConverseOutput)`, and a
1025    /// consumer must be able to read it back as the same type and get the
1026    /// same JSON — including `metrics` and `additional_model_response_fields`,
1027    /// which the normalized response never carries. `AwsConverseOutput` is
1028    /// `Serialize + Deserialize`, so both halves are pinned here. The
1029    /// SDK-typed extras (`trace`, `performance_config`, `service_tier`) are
1030    /// `#[serde(skip)]` and so are absent from the capture by construction;
1031    /// they read back as `None`, which is why the assertion is on the JSON
1032    /// and on the normalized response, not on struct equality with the
1033    /// in-process original.
1034    #[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}