1use super::mappers::{default_cache_point, map_messages, map_tools};
2use super::streaming::process_bedrock_stream;
3use crate::provider::{LlmResponseStream, ProviderFactory, StreamingModelProvider, get_context_window};
4use crate::{Context, LlmError, ProviderAuthMode, ProviderConnectionConfig, Result};
5use aws_config::Region;
6use aws_sdk_bedrockruntime::config::{BehaviorVersion, Credentials};
7use aws_sdk_bedrockruntime::error::SdkError;
8use aws_sdk_bedrockruntime::operation::converse_stream::ConverseStreamError;
9use aws_sdk_bedrockruntime::primitives::event_stream::EventReceiver;
10use aws_sdk_bedrockruntime::types::error::ConverseStreamOutputError;
11use aws_sdk_bedrockruntime::types::{ConverseStreamOutput, InferenceConfiguration};
12use aws_sdk_bedrockruntime::{Client, Config};
13use futures::StreamExt;
14use tracing::{error, info};
15
16const DEFAULT_MODEL: &str = "anthropic.claude-sonnet-4-5-20250929-v1:0";
17const DEFAULT_MAX_TOKENS: i32 = 16_384;
18const DEFAULT_REGION: &str = "us-east-1";
19
20#[derive(Clone)]
22pub struct AwsCredentials {
23 pub access_key_id: String,
24 pub secret_access_key: String,
25 pub session_token: Option<String>,
26}
27
28#[derive(Clone)]
29pub struct BedrockProvider {
30 client: Client,
31 model: String,
32 inference_profile_arn: Option<String>,
33}
34
35impl BedrockProvider {
36 pub async fn new() -> Self {
39 Self::new_with_connection(ProviderConnectionConfig::default()).await
40 }
41
42 pub async fn new_with_connection(connection: ProviderConnectionConfig) -> Self {
43 let client = if connection.auth_mode == ProviderAuthMode::None {
44 build_no_auth_client(connection.base_url.as_deref(), region_from_env().as_deref())
45 } else {
46 let mut loader = aws_config::defaults(BehaviorVersion::latest());
47 if let Some(url) = &connection.base_url {
48 loader = loader.endpoint_url(url.clone());
49 }
50 let config = loader.load().await;
51 Client::new(&config)
52 };
53
54 Self { client, model: DEFAULT_MODEL.to_string(), inference_profile_arn: connection.inference_profile_arn }
55 }
56
57 pub fn from_config(credentials: Option<AwsCredentials>, region: Option<&str>) -> Self {
59 let client = build_client(credentials, region);
60
61 Self { client, model: DEFAULT_MODEL.to_string(), inference_profile_arn: None }
62 }
63
64 pub fn with_model(mut self, model: &str) -> Self {
65 self.model = model.to_string();
66 self
67 }
68
69 pub fn with_inference_profile_arn(mut self, arn: impl Into<String>) -> Self {
70 self.inference_profile_arn = Some(arn.into());
71 self
72 }
73
74 fn request_model_id(&self) -> &str {
75 self.inference_profile_arn.as_deref().unwrap_or(&self.model)
76 }
77
78 async fn send_converse_stream(
79 &self,
80 context: &Context,
81 ) -> Result<EventReceiver<ConverseStreamOutput, ConverseStreamOutputError>> {
82 let cache_point =
83 self.model().is_some_and(|m| m.supports_prompt_caching()).then(default_cache_point).transpose()?;
84 let (system_blocks, messages) = map_messages(context.messages(), cache_point.as_ref())?;
85 let settings = context.model_settings();
86 let max_tokens = settings.max_tokens.and_then(|m| i32::try_from(m).ok()).unwrap_or(DEFAULT_MAX_TOKENS);
87 let mut inference_config = InferenceConfiguration::builder().max_tokens(max_tokens);
88
89 if let Some(temp) = settings.temperature {
90 inference_config = inference_config.temperature(temp);
91 }
92
93 if let Some(top_p) = settings.top_p {
94 inference_config = inference_config.top_p(top_p);
95 }
96
97 let inference_config = inference_config.build();
98
99 let mut request = self
100 .client
101 .converse_stream()
102 .model_id(self.request_model_id())
103 .set_messages(Some(messages))
104 .inference_config(inference_config);
105
106 if !system_blocks.is_empty() {
107 request = request.set_system(Some(system_blocks));
108 }
109
110 if !context.tools().is_empty() {
111 let tool_config = map_tools(context.tools(), cache_point.as_ref())?;
112 request = request.tool_config(tool_config);
113 }
114
115 if let Some(arn) = self.inference_profile_arn.as_deref() {
116 info!(model = %self.model, inference_profile_arn = %arn, "Sending Bedrock converse_stream request");
117 } else {
118 info!(model = %self.model, "Sending Bedrock converse_stream request");
119 }
120
121 let response = request.send().await.map_err(|e| {
122 error!(model = %self.model, error = ?e, "Bedrock API error");
123 LlmError::from(e)
124 })?;
125
126 Ok(response.stream)
127 }
128}
129
130impl ProviderFactory for BedrockProvider {
131 async fn from_env() -> Result<Self> {
132 Ok(Self::new().await)
133 }
134
135 async fn from_env_with_connection(connection: ProviderConnectionConfig) -> Result<Self> {
136 Ok(Self::new_with_connection(connection).await)
137 }
138
139 fn with_model(self, model: &str) -> Self {
140 self.with_model(model)
141 }
142}
143
144impl StreamingModelProvider for BedrockProvider {
145 fn model(&self) -> Option<crate::LlmModel> {
146 format!("bedrock:{}", self.model).parse().ok()
147 }
148
149 fn context_window(&self) -> Option<u32> {
150 get_context_window("bedrock", &self.model)
151 }
152
153 fn stream_response(&self, context: &Context) -> LlmResponseStream {
154 let provider = self.clone();
155 let context = context.clone();
156
157 Box::pin(async_stream::stream! {
158 match provider.send_converse_stream(&context).await {
159 Ok(receiver) => {
160 let mut stream = Box::pin(process_bedrock_stream(receiver));
161 while let Some(result) = stream.next().await {
162 yield result;
163 }
164 }
165 Err(e) => {
166 yield Err(e);
167 }
168 }
169 })
170 }
171
172 fn display_name(&self) -> String {
173 format!("Bedrock ({})", self.model)
174 }
175}
176
177impl From<SdkError<ConverseStreamError>> for LlmError {
178 fn from(e: SdkError<ConverseStreamError>) -> Self {
179 let message = format!("Bedrock API error: {e}");
180 match e {
181 SdkError::TimeoutError(_) => LlmError::Timeout(message),
182 SdkError::DispatchFailure(_) => LlmError::Network(message),
183 SdkError::ResponseError(_) => LlmError::ServerError { status: None, message },
184 SdkError::ServiceError(svc) => {
185 let inner = svc.err();
186 if inner.is_throttling_exception() {
187 LlmError::RateLimited(message)
188 } else if inner.is_service_unavailable_exception()
189 || inner.is_internal_server_exception()
190 || inner.is_model_stream_error_exception()
191 {
192 LlmError::ServerError { status: None, message }
193 } else {
194 LlmError::ApiError(message)
195 }
196 }
197 _ => LlmError::ApiError(message),
198 }
199 }
200}
201
202fn build_client(credentials: Option<AwsCredentials>, region: Option<&str>) -> Client {
203 let mut config = Config::builder().behavior_version(BehaviorVersion::latest());
204
205 if let Some(creds) = credentials {
206 config = config.credentials_provider(Credentials::new(
207 creds.access_key_id,
208 creds.secret_access_key,
209 creds.session_token,
210 None,
211 "aether-bedrock-provider",
212 ));
213 }
214
215 config = config.region(Region::new(region.unwrap_or(DEFAULT_REGION).to_string()));
216
217 Client::from_conf(config.build())
218}
219
220fn build_no_auth_client(base_url: Option<&str>, region: Option<&str>) -> Client {
221 let mut config = Config::builder()
222 .behavior_version(BehaviorVersion::latest())
223 .allow_no_auth()
224 .region(Region::new(region.unwrap_or(DEFAULT_REGION).to_string()));
225
226 if let Some(url) = base_url {
227 config = config.endpoint_url(url);
228 }
229
230 Client::from_conf(config.build())
231}
232
233fn region_from_env() -> Option<String> {
234 ["AWS_REGION", "AWS_DEFAULT_REGION"].into_iter().find_map(|name| match std::env::var(name) {
235 Ok(value) if !value.is_empty() => Some(value),
236 _ => None,
237 })
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use crate::types::IsoString;
244 use crate::{ChatMessage, ContentBlock};
245 use axum::Router;
246 use axum::body::Body;
247 use axum::extract::State;
248 use axum::http::{HeaderMap, Method, Request, StatusCode};
249 use axum::response::IntoResponse;
250 use axum::routing::any;
251 use std::sync::Arc;
252 use tokio::net::TcpListener;
253 use tokio::sync::{Mutex, oneshot};
254
255 fn inference_profile_arn(model: &str) -> String {
256 format!("arn:aws:bedrock:us-west-2:000000000000:inference-profile/{model}")
257 }
258
259 fn application_inference_profile_arn() -> &'static str {
260 "arn:aws:bedrock:us-west-2:000000000000:application-inference-profile/000000000000"
261 }
262
263 fn test_provider() -> BedrockProvider {
264 BedrockProvider::from_config(None, None)
265 }
266
267 #[test]
268 fn test_display_name() {
269 assert_eq!(test_provider().display_name(), "Bedrock (anthropic.claude-sonnet-4-5-20250929-v1:0)");
270 }
271
272 #[test]
273 fn test_with_model() {
274 let provider = test_provider().with_model("anthropic.claude-opus-4-20250514-v1:0");
275 assert_eq!(provider.display_name(), "Bedrock (anthropic.claude-opus-4-20250514-v1:0)");
276 }
277
278 #[test]
279 fn test_default_values() {
280 let provider = test_provider();
281 assert_eq!(provider.model, "anthropic.claude-sonnet-4-5-20250929-v1:0");
282 }
283
284 #[tokio::test]
285 async fn auth_none_sends_unsigned_request_to_custom_endpoint() {
286 let endpoint = FakeBedrockEndpoint::start().await;
287 let provider = BedrockProvider::new_with_connection(ProviderConnectionConfig {
288 base_url: Some(endpoint.url.clone()),
289 auth_mode: ProviderAuthMode::None,
290 ..Default::default()
291 })
292 .await;
293
294 let context = Context::new(
295 vec![ChatMessage::User { content: vec![ContentBlock::text("hello")], timestamp: IsoString::now() }],
296 vec![],
297 );
298
299 let result = provider.send_converse_stream(&context).await;
300 let request = endpoint.request.await.expect("fake Bedrock endpoint received no request");
301
302 assert!(result.is_err());
303 assert_eq!(request.method, Method::POST);
304 assert!(request.path.starts_with("/model/"), "{}", request.path);
305 assert!(!request.headers.contains_key("authorization"), "request was signed: {:?}", request.headers);
306 assert!(
307 !request.headers.contains_key("x-amz-security-token"),
308 "request included session token: {:?}",
309 request.headers
310 );
311 }
312
313 #[test]
314 fn test_from_config_with_credentials() {
315 let credentials = AwsCredentials {
316 access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
317 secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
318 session_token: None,
319 };
320
321 let provider = BedrockProvider::from_config(Some(credentials), None);
322 assert_eq!(provider.model, DEFAULT_MODEL);
323 }
324
325 #[test]
326 fn test_from_config_with_credentials_and_region() {
327 let credentials = AwsCredentials {
328 access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
329 secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
330 session_token: Some("FwoGZXIvYXdzEBYaD...".to_string()),
331 };
332
333 let provider = BedrockProvider::from_config(Some(credentials), Some("us-west-2"))
334 .with_model("anthropic.claude-opus-4-20250514-v1:0");
335
336 assert_eq!(provider.model, "anthropic.claude-opus-4-20250514-v1:0");
337 }
338
339 #[test]
340 fn test_from_config_with_region_only() {
341 let provider = BedrockProvider::from_config(None, Some("eu-west-1"));
342 assert_eq!(provider.model, DEFAULT_MODEL);
343 }
344
345 #[test]
346 fn catalog_foundation_id_resolves_context_window() {
347 let provider = test_provider().with_model("anthropic.claude-sonnet-4-5-20250929-v1:0");
348 assert!(provider.context_window().is_some());
349 assert_eq!(provider.model().unwrap().to_string(), "bedrock:anthropic.claude-sonnet-4-5-20250929-v1:0");
350 }
351
352 #[test]
353 fn cross_region_profile_id_in_catalog_resolves() {
354 let provider = test_provider().with_model("us.anthropic.claude-opus-4-6-v1");
355 assert!(provider.context_window().is_some());
356 }
357
358 #[test]
359 fn unknown_cross_region_profile_id_falls_through_to_profile() {
360 let id = "us.anthropic.claude-future-model-v99:0";
361 let provider = test_provider().with_model(id);
362 assert_eq!(provider.context_window(), None);
363 assert_eq!(provider.model().unwrap().to_string(), format!("bedrock:{id}"));
364 assert_eq!(provider.display_name(), format!("Bedrock ({id})"));
365 }
366
367 #[tokio::test]
368 async fn separate_inference_profile_arn_is_used_as_request_model_id() {
369 let endpoint = FakeBedrockEndpoint::start().await;
370 let provider = BedrockProvider::new_with_connection(ProviderConnectionConfig {
371 base_url: Some(endpoint.url.clone()),
372 auth_mode: ProviderAuthMode::None,
373 inference_profile_arn: Some(application_inference_profile_arn().to_string()),
374 })
375 .await
376 .with_model("anthropic.claude-sonnet-4-5-20250929-v1:0");
377 let context = Context::new(
378 vec![ChatMessage::User { content: vec![ContentBlock::text("hello")], timestamp: IsoString::now() }],
379 vec![],
380 );
381
382 let result = provider.send_converse_stream(&context).await;
383 let request = endpoint.request.await.expect("fake Bedrock endpoint received no request");
384
385 assert!(result.is_err());
386 assert!(
387 request.path.contains("arn%3Aaws%3Abedrock%3Aus-west-2%3A000000000000%3Aapplication-inference-profile"),
388 "{}",
389 request.path
390 );
391 assert_eq!(provider.context_window(), Some(200_000));
392 assert_eq!(provider.model().unwrap().to_string(), "bedrock:anthropic.claude-sonnet-4-5-20250929-v1:0");
393 }
394
395 #[test]
396 fn with_inference_profile_arn_keeps_canonical_model_identity() {
397 let arn = inference_profile_arn("us.anthropic.claude-sonnet-4-5-20250929-v1:0");
398 let provider =
399 test_provider().with_model("anthropic.claude-sonnet-4-5-20250929-v1:0").with_inference_profile_arn(&arn);
400
401 assert_eq!(provider.request_model_id(), arn);
402 assert_eq!(provider.context_window(), Some(200_000));
403 assert_eq!(provider.model().unwrap().to_string(), "bedrock:anthropic.claude-sonnet-4-5-20250929-v1:0");
404 }
405
406 #[test]
407 fn prompt_caching_support_comes_from_canonical_model() {
408 let cached = test_provider().with_model("anthropic.claude-sonnet-4-5-20250929-v1:0");
409 assert!(cached.model().unwrap().supports_prompt_caching());
410
411 let unknown_profile = test_provider().with_model("us.anthropic.claude-future-model-v99:0");
412 assert!(!unknown_profile.model().unwrap().supports_prompt_caching());
413 }
414
415 struct FakeBedrockEndpoint {
416 url: String,
417 request: oneshot::Receiver<CapturedRequest>,
418 }
419
420 struct CapturedRequest {
421 method: Method,
422 path: String,
423 headers: HeaderMap,
424 }
425
426 #[derive(Clone)]
427 struct FakeBedrockState {
428 request_tx: Arc<Mutex<Option<oneshot::Sender<CapturedRequest>>>>,
429 shutdown_tx: Arc<Mutex<Option<oneshot::Sender<()>>>>,
430 }
431
432 impl FakeBedrockEndpoint {
433 async fn start() -> Self {
434 let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind fake Bedrock endpoint");
435 let url = format!("http://{}", listener.local_addr().expect("fake Bedrock endpoint address"));
436 let (request_tx, request) = oneshot::channel();
437 let (shutdown_tx, shutdown) = oneshot::channel();
438 let state = FakeBedrockState {
439 request_tx: Arc::new(Mutex::new(Some(request_tx))),
440 shutdown_tx: Arc::new(Mutex::new(Some(shutdown_tx))),
441 };
442
443 let app = Router::new().fallback(any(capture_bedrock_request)).with_state(state);
444 tokio::spawn(async move {
445 axum::serve(listener, app)
446 .with_graceful_shutdown(async {
447 let _ = shutdown.await;
448 })
449 .await
450 .expect("serve fake Bedrock endpoint");
451 });
452
453 Self { url, request }
454 }
455 }
456
457 async fn capture_bedrock_request(
458 State(state): State<FakeBedrockState>,
459 request: Request<Body>,
460 ) -> impl IntoResponse {
461 let (parts, _) = request.into_parts();
462 if let Some(tx) = state.request_tx.lock().await.take() {
463 let _ = tx.send(CapturedRequest {
464 method: parts.method,
465 path: parts.uri.path().to_string(),
466 headers: parts.headers,
467 });
468 }
469 if let Some(tx) = state.shutdown_tx.lock().await.take() {
470 let _ = tx.send(());
471 }
472 (StatusCode::FORBIDDEN, "{}")
473 }
474}