use aws_config::BehaviorVersion;
use aws_sdk_bedrockruntime::Client as BedrockClient;
use super::{
BedrockProvider, DEFAULT_REGION, LlmRequest, estimate_bedrock_cost_usd, normalize_model_family,
resolve_bedrock_region, validate_model_id,
};
use crate::llm::bedrock::tool_use::build_tool_config;
use crate::llm::{ChatMessage, LlmError, LlmProvider, ResponseSchema};
#[test]
fn bedrock_region_resolution() {
assert_eq!(
resolve_bedrock_region(Some("eu-west-1")),
"eu-west-1",
"explicit should win"
);
assert_eq!(
resolve_bedrock_region(Some("")),
DEFAULT_REGION,
"empty explicit should fall through to default"
);
assert_eq!(
resolve_bedrock_region(None),
DEFAULT_REGION,
"None should return default"
);
}
#[test]
fn bedrock_us_prefix_validation() {
for id in [
"us.anthropic.claude-sonnet-4-6",
"eu.anthropic.claude-sonnet-4-6",
"ap.anthropic.claude-sonnet-4-6",
"jp.anthropic.claude-sonnet-4-6",
"global.anthropic.claude-sonnet-4-6",
] {
assert!(
validate_model_id(id).is_ok(),
"expected {id:?} to pass validation"
);
}
let err = validate_model_id("anthropic.claude-sonnet-4-6").unwrap_err();
assert!(
matches!(err, LlmError::Validation(_)),
"expected Validation error for bare id"
);
assert!(err.is_alarm(), "Validation is an alarm error");
assert!(!err.is_retryable(), "Validation must not be retried");
}
#[test]
fn bedrock_empty_model_id_is_validation_error() {
let err = validate_model_id("").unwrap_err();
assert!(matches!(err, LlmError::Validation(_)));
}
#[test]
fn bedrock_cost_estimate_sonnet() {
let cost = estimate_bedrock_cost_usd("us.anthropic.claude-sonnet-4-6", 1_000_000, 1_000_000);
assert!(
(cost - 18.0_f64).abs() < 1e-9,
"expected $18.00 for 1M+1M Sonnet tokens, got {cost}"
);
}
#[test]
fn bedrock_cost_estimate_eu_prefix_normalized() {
let eu_cost = estimate_bedrock_cost_usd("eu.anthropic.claude-sonnet-4-6", 1_000_000, 1_000_000);
let us_cost = estimate_bedrock_cost_usd("us.anthropic.claude-sonnet-4-6", 1_000_000, 1_000_000);
assert!(
(eu_cost - us_cost).abs() < 1e-9,
"eu. and us. prefixes should give identical cost: eu={eu_cost} us={us_cost}"
);
}
#[test]
fn bedrock_cost_estimate_haiku() {
let cost = estimate_bedrock_cost_usd("us.anthropic.claude-haiku-4-5", 1_000_000, 1_000_000);
assert!(
(cost - 4.8_f64).abs() < 1e-9,
"expected $4.80 for 1M+1M Haiku tokens (short id), got {cost}"
);
}
#[test]
fn bedrock_cost_estimate_haiku_date_versioned() {
let cost = estimate_bedrock_cost_usd(
"us.anthropic.claude-haiku-4-5-20251001-v1:0",
1_000_000,
1_000_000,
);
assert!(
(cost - 4.8_f64).abs() < 1e-9,
"expected $4.80 for 1M+1M Haiku tokens (date-versioned id), got {cost}. \
The normalize_model_family() function must strip -20251001-v1:0 to match \
the pricing table entry."
);
}
#[test]
fn bedrock_normalize_model_family_strips_suffix() {
assert_eq!(
normalize_model_family("anthropic.claude-haiku-4-5-20251001-v1:0"),
"anthropic.claude-haiku-4-5",
"date+version suffix must be stripped"
);
assert_eq!(
normalize_model_family("anthropic.claude-3-5-sonnet-20241022"),
"anthropic.claude-3-5-sonnet",
"date-only suffix must be stripped"
);
assert_eq!(
normalize_model_family("anthropic.claude-3-haiku-20240307-v1:0"),
"anthropic.claude-3-haiku",
"date+version suffix must be stripped from legacy Haiku"
);
assert_eq!(
normalize_model_family("anthropic.claude-sonnet-4-6"),
"anthropic.claude-sonnet-4-6",
"id without date/version suffix must be unchanged"
);
assert_eq!(
normalize_model_family("anthropic.claude-haiku-4-5"),
"anthropic.claude-haiku-4-5",
"short haiku id must be unchanged"
);
}
#[test]
fn bedrock_cost_estimate_sonnet_4_5_date_versioned() {
let cost = estimate_bedrock_cost_usd(
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
1_000_000,
1_000_000,
);
assert!(
(cost - 18.0_f64).abs() < 1e-9,
"expected $18.00 for 1M+1M Sonnet 4.5 tokens (date-versioned id), got {cost}"
);
}
#[test]
fn bedrock_cost_estimate_unknown_model() {
let cost = estimate_bedrock_cost_usd("us.unknown/model-xyz", 500_000, 100_000);
assert_eq!(cost, 0.0, "unknown model cost must be 0.0");
}
#[tokio::test]
async fn bedrock_provider_stores_model_and_region() {
let config = aws_config::defaults(BehaviorVersion::latest())
.region(aws_types::region::Region::new("us-east-1"))
.no_credentials()
.load()
.await;
let client = BedrockClient::new(&config);
let provider =
BedrockProvider::from_client(client, "us.anthropic.claude-sonnet-4-6", "us-east-1");
assert_eq!(provider.name(), "bedrock");
assert_eq!(provider.region(), "us-east-1");
}
#[tokio::test]
async fn bedrock_no_credentials_returns_error() {
let config = aws_config::defaults(BehaviorVersion::latest())
.region(aws_types::region::Region::new("us-east-1"))
.no_credentials()
.load()
.await;
let client = BedrockClient::new(&config);
let provider =
BedrockProvider::from_client(client, "us.anthropic.claude-sonnet-4-6", "us-east-1");
let req = LlmRequest {
model: "us.anthropic.claude-sonnet-4-6".to_string(),
system: "You are a code reviewer.".to_string(),
messages: vec![ChatMessage {
role: "user".to_string(),
content: "Review this diff.".to_string(),
}],
temperature: 0.3,
max_tokens: 512,
response_schema: None,
};
let result = provider.complete(req).await;
let err = result.expect_err("should fail without real credentials");
let msg = format!("{err}");
let mentions_context = msg.to_lowercase().contains("bedrock")
|| msg.to_lowercase().contains("credential")
|| msg.to_lowercase().contains("aws")
|| msg.to_lowercase().contains("access")
|| err.is_alarm();
assert!(
mentions_context,
"error should mention Bedrock/credentials/AWS; got: {msg}"
);
}
#[test]
fn bedrock_converse_request_construction() {
let req = LlmRequest {
model: "us.anthropic.claude-sonnet-4-6".to_string(),
system: "You are a Rust code reviewer.".to_string(),
messages: vec![ChatMessage {
role: "user".to_string(),
content: "Review this diff.".to_string(),
}],
temperature: 0.3,
max_tokens: 1024,
response_schema: None,
};
assert!(!req.system.is_empty(), "system message must be forwarded");
assert_eq!(req.messages.len(), 1);
assert_eq!(req.messages[0].role, "user");
assert_eq!(req.messages[0].content, "Review this diff.");
assert!(
req.temperature >= 0.0 && req.temperature <= 1.0,
"temperature must be in [0.0, 1.0] for Bedrock"
);
assert!(req.max_tokens > 0, "max_tokens must be > 0");
}
#[test]
fn bedrock_request_includes_tool_config_when_schema_set() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"verdict": {"type": "string"},
"summary": {"type": "string"},
"findings": {"type": "array", "items": {"type": "object"}}
},
"required": ["verdict", "summary", "findings"]
});
let req = LlmRequest {
model: "us.anthropic.claude-sonnet-4-6".to_string(),
system: "reviewer".to_string(),
messages: vec![ChatMessage {
role: "user".to_string(),
content: "review".to_string(),
}],
temperature: 0.3,
max_tokens: 1024,
response_schema: Some(ResponseSchema {
name: "review_output".to_string(),
schema: schema.clone(),
}),
};
assert!(
req.response_schema.is_some(),
"response_schema must be set on the request"
);
let tool_config_result = build_tool_config("review_output", &schema);
assert!(
tool_config_result.is_ok(),
"build_tool_config must succeed for the review schema: {:?}",
tool_config_result.err()
);
}
#[tokio::test]
async fn bedrock_structured_no_credentials_returns_error() {
let config = aws_config::defaults(BehaviorVersion::latest())
.region(aws_types::region::Region::new("us-east-1"))
.no_credentials()
.load()
.await;
let client = BedrockClient::new(&config);
let provider =
BedrockProvider::from_client(client, "us.anthropic.claude-sonnet-4-6", "us-east-1");
let schema = serde_json::json!({
"type": "object",
"properties": {"verdict": {"type": "string"}},
"required": ["verdict"]
});
let req = LlmRequest {
model: "us.anthropic.claude-sonnet-4-6".to_string(),
system: "reviewer".to_string(),
messages: vec![ChatMessage {
role: "user".to_string(),
content: "review diff".to_string(),
}],
temperature: 0.3,
max_tokens: 512,
response_schema: Some(ResponseSchema {
name: "review_output".to_string(),
schema,
}),
};
let result = provider.complete(req).await;
assert!(
result.is_err(),
"must fail without real credentials even with tool-use schema"
);
}