opendev_http/adapters/bedrock/mod.rs
1//! AWS Bedrock provider adapter.
2//!
3//! Transforms OpenAI Chat Completions payloads to Amazon Bedrock's
4//! `InvokeModel` format and converts responses back.
5//!
6//! Bedrock uses SigV4 request signing. Since `aws-sigv4` is not available
7//! as a dependency, the signing logic is stubbed with a TODO. In production,
8//! either add `aws-sigv4`/`aws-credential-types` crates or implement
9//! minimal HMAC-SHA256 signing with the `hmac` and `sha2` crates.
10//!
11//! Environment variables:
12//! - `AWS_ACCESS_KEY_ID` — IAM access key
13//! - `AWS_SECRET_ACCESS_KEY` — IAM secret key
14//! - `AWS_REGION` — AWS region (defaults to `us-east-1`)
15//! - `AWS_SESSION_TOKEN` — optional session token for temporary credentials
16
17mod request;
18mod response;
19
20use serde_json::{Value, json};
21
22/// Default AWS region when `AWS_REGION` is not set.
23const DEFAULT_REGION: &str = "us-east-1";
24
25/// Adapter for Amazon Bedrock's InvokeModel API.
26///
27/// Bedrock wraps foundation models behind a REST API at:
28/// `https://bedrock-runtime.{region}.amazonaws.com/model/{model_id}/invoke`
29///
30/// This adapter handles:
31/// - Converting Chat Completions messages to Bedrock's Anthropic-style format
32/// - Building the correct endpoint URL from region + model
33/// - SigV4 header generation (TODO: requires `hmac`/`sha2` crates)
34#[derive(Debug, Clone)]
35pub struct BedrockAdapter {
36 region: String,
37 model_id: String,
38 api_url: String,
39}
40
41impl BedrockAdapter {
42 /// Create a new Bedrock adapter for the given model.
43 ///
44 /// Reads `AWS_REGION` from the environment (defaults to `us-east-1`).
45 pub fn new(model_id: impl Into<String>) -> Self {
46 let model_id = model_id.into();
47 let region = std::env::var("AWS_REGION").unwrap_or_else(|_| DEFAULT_REGION.to_string());
48 let api_url = Self::build_url(®ion, &model_id);
49 Self {
50 region,
51 model_id,
52 api_url,
53 }
54 }
55
56 /// Create a new Bedrock adapter with a custom region.
57 pub fn with_region(model_id: impl Into<String>, region: impl Into<String>) -> Self {
58 let model_id = model_id.into();
59 let region = region.into();
60 let api_url = Self::build_url(®ion, &model_id);
61 Self {
62 region,
63 model_id,
64 api_url,
65 }
66 }
67
68 /// Build the Bedrock InvokeModel URL.
69 fn build_url(region: &str, model_id: &str) -> String {
70 format!("https://bedrock-runtime.{region}.amazonaws.com/model/{model_id}/invoke")
71 }
72
73 /// Get the configured AWS region.
74 pub fn region(&self) -> &str {
75 &self.region
76 }
77
78 /// Get the model ID.
79 pub fn model_id(&self) -> &str {
80 &self.model_id
81 }
82
83 /// Generate SigV4 authorization headers for the request.
84 ///
85 /// TODO: Implement SigV4 signing. Requires either:
86 /// - Adding `aws-sigv4` + `aws-credential-types` crates, or
87 /// - Adding `hmac` + `sha2` crates for minimal manual signing.
88 ///
89 /// For now, this returns empty headers. To use Bedrock in production,
90 /// implement the SigV4 signing algorithm:
91 /// 1. Create canonical request (method, URI, query, headers, payload hash)
92 /// 2. Create string-to-sign (algorithm, date, scope, canonical request hash)
93 /// 3. Derive signing key via HMAC-SHA256 chain (date → region → service → signing)
94 /// 4. Calculate signature = HMAC-SHA256(signing_key, string_to_sign)
95 /// 5. Build Authorization header
96 #[allow(dead_code)]
97 fn sigv4_headers(&self, _payload: &[u8]) -> Vec<(String, String)> {
98 let _access_key = std::env::var("AWS_ACCESS_KEY_ID").unwrap_or_default();
99 let _secret_key = std::env::var("AWS_SECRET_ACCESS_KEY").unwrap_or_default();
100 let _session_token = std::env::var("AWS_SESSION_TOKEN").ok();
101
102 // TODO: Implement SigV4 signing when `hmac` and `sha2` crates are available.
103 vec![
104 ("Content-Type".into(), "application/json".into()),
105 ("Accept".into(), "application/json".into()),
106 ]
107 }
108}
109
110#[async_trait::async_trait]
111impl super::base::ProviderAdapter for BedrockAdapter {
112 fn provider_name(&self) -> &str {
113 "bedrock"
114 }
115
116 fn convert_request(&self, mut payload: Value) -> Value {
117 // Strip internal reasoning effort field (Bedrock doesn't support it)
118 payload
119 .as_object_mut()
120 .map(|obj| obj.remove("_reasoning_effort"));
121
122 request::extract_system(&mut payload);
123 request::convert_tools(&mut payload);
124 request::convert_tool_messages(&mut payload);
125 request::ensure_max_tokens(&mut payload);
126
127 // Bedrock wraps the model in the URL, not the payload.
128 // Remove fields Bedrock does not accept.
129 if let Some(obj) = payload.as_object_mut() {
130 obj.remove("model");
131 obj.remove("n");
132 obj.remove("frequency_penalty");
133 obj.remove("presence_penalty");
134 obj.remove("logprobs");
135 obj.remove("stream");
136 }
137
138 // Set anthropic_version required by Bedrock's Anthropic models.
139 payload["anthropic_version"] = json!("bedrock-2023-05-31");
140
141 payload
142 }
143
144 fn convert_response(&self, response: Value) -> Value {
145 response::response_to_chat_completions(response, &self.model_id)
146 }
147
148 fn api_url(&self) -> &str {
149 &self.api_url
150 }
151
152 fn extra_headers(&self) -> Vec<(String, String)> {
153 // TODO: SigV4 headers should be generated per-request with the payload.
154 // The ProviderAdapter trait's `extra_headers()` is called without payload
155 // context, so full SigV4 signing would require a trait extension.
156 // For now, return content-type headers only.
157 vec![
158 ("Content-Type".into(), "application/json".into()),
159 ("Accept".into(), "application/json".into()),
160 ]
161 }
162}
163
164#[cfg(test)]
165mod tests;