Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 108 additions & 13 deletions crates/rig-bedrock/src/types/assistant_content.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
use aws_sdk_bedrockruntime::types as aws_bedrock;

use rig_core::{
OneOrMany,
completion::CompletionError,
message::{AssistantContent, Text},
};

use serde::{Deserialize, Serialize};

use crate::types::message::RigMessage;

use super::{
converse_output::{ContentBlock, InternalConverseOutput, TokenUsage},
converse_output::{ContentBlock, InternalConverseOutput, StopReason, TokenUsage},
json::AwsDocument,
};
use rig_core::completion::{self, GetTokenUsage};
Expand Down Expand Up @@ -85,27 +87,57 @@
type Error = CompletionError;

fn try_from(value: AwsConverseOutput) -> Result<Self, Self::Error> {
let message: RigMessage = value
let stop_reason = value.0.stop_reason.clone();

let aws_message = value
.to_owned()
.0
.output
.ok_or(CompletionError::ProviderError(
"Model didn't return any output".into(),
))?
.ok_or_else(|| {
CompletionError::ResponseError(format!(
"Model returned no output (stop_reason={stop_reason})"
))
})?
.as_message()
.map_err(|_| {
CompletionError::ProviderError(
"Failed to extract message from converse output".into(),
)
CompletionError::ResponseError(format!(
"Failed to extract message from converse output (stop_reason={stop_reason})"
))
})?
.to_owned()
.try_into()?;
.to_owned();

// Handle empty content blocks. When `stop_reason=end_turn` we treat this
// as a benign empty response and synthesize a single empty text block so
// downstream code receives a well-formed `CompletionResponse` instead of
// failing on `OneOrMany::many`. For every other stop reason (guardrail
// intervention, content filter, max_tokens exhaustion, etc.) the empty
// response is surfaced as a `ResponseError` carrying the stop_reason so
// callers can branch.
if aws_message.content.is_empty() {
return match stop_reason {
StopReason::EndTurn => {
let choice = OneOrMany::one(AssistantContent::Text(Text::new("")));
let usage = value.0.usage().map(normalize_usage).unwrap_or_default();
Ok(completion::CompletionResponse {
choice,
usage,
raw_response: value,
message_id: None,
})
}
_ => Err(CompletionError::ResponseError(format!(
"No assistance content returned (stop_reason={stop_reason})"
))),
};
}

let message: RigMessage = aws_message.try_into()?;

let choice = match message.0 {
completion::Message::Assistant { content, .. } => Ok(content),
_ => Err(CompletionError::ResponseError(
"Response contained no message or tool call (empty)".to_owned(),
)),
_ => Err(CompletionError::ResponseError(format!(
"Response contained no message or tool call (stop_reason={stop_reason})"
))),
}?;

let usage = value.0.usage().map(normalize_usage).unwrap_or_default();
Expand Down Expand Up @@ -369,6 +401,69 @@
);
}

fn make_output_with_content_and_stop_reason(
content: Vec<aws_bedrock::ContentBlock>,
stop_reason: aws_bedrock::StopReason,
) -> AwsConverseOutput {
let message = aws_bedrock::Message::builder()
.role(aws_bedrock::ConversationRole::Assistant)
.set_content(Some(content))
.build()
.unwrap();
let builder = aws_sdk_bedrockruntime::operation::converse::ConverseOutput::builder()
.output(aws_bedrock::ConverseOutput::Message(message))
.stop_reason(stop_reason);
let internal: InternalConverseOutput = builder.build().unwrap().try_into().unwrap();
AwsConverseOutput(internal)
}

#[test]
fn empty_content_with_end_turn_yields_empty_text_choice() {
let output = make_output_with_content_and_stop_reason(
vec![],
aws_bedrock::StopReason::EndTurn,
);
let completion: completion::CompletionResponse<AwsConverseOutput> = output
.try_into()

Check warning on line 427 in crates/rig-bedrock/src/types/assistant_content.rs

View workflow job for this annotation

GitHub Actions / stable / fmt

Diff in /home/runner/work/rig/rig/crates/rig-bedrock/src/types/assistant_content.rs
.expect("end_turn with empty content should not error");
assert_eq!(
completion.choice,
OneOrMany::one(AssistantContent::Text("".into()))
);
}

#[test]
fn empty_content_with_max_tokens_yields_response_error() {
let output = make_output_with_content_and_stop_reason(
vec![],
aws_bedrock::StopReason::MaxTokens,
);
let result: Result<completion::CompletionResponse<AwsConverseOutput>, _> =
output.try_into();

Check warning on line 442 in crates/rig-bedrock/src/types/assistant_content.rs

View workflow job for this annotation

GitHub Actions / stable / fmt

Diff in /home/runner/work/rig/rig/crates/rig-bedrock/src/types/assistant_content.rs
let Err(err) = result else {
panic!("max_tokens with empty content should error");
};
let msg = err.to_string();
assert!(
msg.contains("max_tokens"),
"error should mention stop_reason: {msg}"
);
}

#[test]
fn empty_content_with_guardrail_yields_response_error() {
let output = make_output_with_content_and_stop_reason(
vec![],
aws_bedrock::StopReason::GuardrailIntervened,
);
let result: Result<completion::CompletionResponse<AwsConverseOutput>, _> =
output.try_into();
let Err(err) = result else {
panic!("guardrail with empty content should error");
};
assert!(err.to_string().contains("guardrail_intervened"));
}

#[test]
fn aws_converse_output_preserves_parallel_tool_calls_in_completion_response() {
let content = vec![
Expand Down
14 changes: 14 additions & 0 deletions crates/rig-bedrock/src/types/converse_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,20 @@ pub enum StopReason {
/// the crate.
///
/// This is not intended to be used directly.
impl fmt::Display for StopReason {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
StopReason::ContentFiltered => write!(f, "content_filtered"),
StopReason::EndTurn => write!(f, "end_turn"),
StopReason::GuardrailIntervened => write!(f, "guardrail_intervened"),
StopReason::MaxTokens => write!(f, "max_tokens"),
StopReason::StopSequence => write!(f, "stop_sequence"),
StopReason::ToolUse => write!(f, "tool_use"),
StopReason::Unknown(value) => write!(f, "unknown({value})"),
}
}
}

#[derive(Clone, Eq, Ord, PartialEq, PartialOrd, Debug, Hash, Serialize, Deserialize)]
pub struct UnknownVariantValue(pub(crate) String);

Expand Down
19 changes: 15 additions & 4 deletions crates/rig-bedrock/src/types/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,16 @@
.map(|rig_assistant_content| rig_assistant_content.0)
.collect::<Vec<AssistantContent>>();

let content = OneOrMany::many(assistant_content)
.map_err(|e| CompletionError::RequestError(Box::new(e)))?;
// Response-side failure: Bedrock returned an assistant message
// with zero content blocks. Surface as ResponseError; the
// `RequestError` variant is reserved for outgoing request
// construction failures.

Check warning on line 70 in crates/rig-bedrock/src/types/message.rs

View workflow job for this annotation

GitHub Actions / stable / fmt

Diff in /home/runner/work/rig/rig/crates/rig-bedrock/src/types/message.rs
let content = OneOrMany::many(assistant_content).map_err(|_| {
CompletionError::ResponseError(
"Bedrock returned an assistant message with no content blocks"
.to_owned(),
)
})?;

Ok(RigMessage(Message::Assistant { content, id: None }))
}
Expand All @@ -79,8 +87,11 @@
.map(|user_content| user_content.0)
.collect::<Vec<UserContent>>();

let content = OneOrMany::many(user_content)
.map_err(|e| CompletionError::RequestError(Box::new(e)))?;
let content = OneOrMany::many(user_content).map_err(|_| {
CompletionError::ResponseError(
"Bedrock returned a user message with no content blocks".to_owned(),
)
})?;
Ok(RigMessage(Message::User { content }))
}
_ => Err(CompletionError::ProviderError(
Expand Down
Loading