Skip to main content

mentedb_embedding/
bedrock_provider.rs

1//! AWS Bedrock embedding provider (Amazon Titan and Cohere Embed) over the
2//! InvokeModel REST API.
3//!
4//! Deliberately dependency light: a synchronous `ureq` call plus a small
5//! hand-rolled SigV4 signer, so the engine gains native Bedrock support without
6//! pulling in the full async AWS SDK. This matches the lean, no-heavy-deps
7//! philosophy of the rest of the engine (see the `http` provider).
8//!
9//! Credentials come from [`AwsCredentialSource`]: static keys from the
10//! environment, or the ECS task role / EKS Pod Identity endpoint, refreshed
11//! before they expire.
12//!
13//! ```no_run
14//! use mentedb_embedding::bedrock_provider::{BedrockEmbeddingConfig, BedrockEmbeddingProvider};
15//! use mentedb_embedding::provider::EmbeddingProvider;
16//!
17//! let cfg = BedrockEmbeddingConfig::titan_v2("us-east-1")?; // reads creds from env
18//! let provider = BedrockEmbeddingProvider::new(cfg)?;
19//! let vector = provider.embed("the sky is blue")?;
20//! # Ok::<(), mentedb_core::error::MenteError>(())
21//! ```
22
23use hmac::{Hmac, Mac};
24use sha2::{Digest, Sha256};
25
26use mentedb_core::error::{MenteError, MenteResult};
27
28use crate::aws_credentials::AwsCredentialSource;
29use crate::provider::EmbeddingProvider;
30
31type HmacSha256 = Hmac<Sha256>;
32
33const SERVICE: &str = "bedrock";
34
35fn hex(bytes: &[u8]) -> String {
36    let mut s = String::with_capacity(bytes.len() * 2);
37    for b in bytes {
38        s.push_str(&format!("{b:02x}"));
39    }
40    s
41}
42
43fn sha256_hex(data: &[u8]) -> String {
44    hex(&Sha256::digest(data))
45}
46
47fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
48    let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
49    mac.update(data);
50    mac.finalize().into_bytes().to_vec()
51}
52
53/// SigV4 signing key: HMAC chain over date, region, service, "aws4_request".
54fn signing_key(secret: &str, datestamp: &str, region: &str, service: &str) -> Vec<u8> {
55    let k_date = hmac_sha256(format!("AWS4{secret}").as_bytes(), datestamp.as_bytes());
56    let k_region = hmac_sha256(&k_date, region.as_bytes());
57    let k_service = hmac_sha256(&k_region, service.as_bytes());
58    hmac_sha256(&k_service, b"aws4_request")
59}
60
61/// URI-encode a single path segment per SigV4 rules (unreserved chars pass
62/// through; everything else, including the `:` in a model id, is percent
63/// encoded). The request URL and the signed canonical URI must match exactly.
64fn uri_encode_segment(s: &str) -> String {
65    let mut out = String::with_capacity(s.len());
66    for b in s.bytes() {
67        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
68            out.push(b as char);
69        } else {
70            out.push_str(&format!("%{b:02X}"));
71        }
72    }
73    out
74}
75
76/// Most texts Cohere Embed accepts in one InvokeModel call.
77const COHERE_MAX_BATCH: usize = 96;
78
79/// Request and response shape of a Bedrock embedding model.
80#[derive(Clone, Copy, Debug, PartialEq, Eq)]
81pub enum BedrockEmbeddingFamily {
82    /// Amazon Titan: one text per call.
83    Titan,
84    /// Cohere Embed: up to 96 texts per call.
85    Cohere,
86}
87
88impl BedrockEmbeddingFamily {
89    /// Family for a model id, by its provider prefix.
90    pub fn for_model(model_id: &str) -> MenteResult<Self> {
91        let bare = model_id.rsplit('/').next().unwrap_or(model_id);
92        let bare = bare
93            .strip_prefix("us.")
94            .or_else(|| bare.strip_prefix("eu."))
95            .or_else(|| bare.strip_prefix("apac."))
96            .or_else(|| bare.strip_prefix("global."))
97            .unwrap_or(bare);
98        if bare.starts_with("amazon.titan-embed") {
99            Ok(Self::Titan)
100        } else if bare.starts_with("cohere.embed") {
101            Ok(Self::Cohere)
102        } else {
103            Err(MenteError::Storage(format!(
104                "unsupported Bedrock embedding model '{model_id}' (expected amazon.titan-embed-* \
105                 or cohere.embed-*)"
106            )))
107        }
108    }
109}
110
111/// Configuration for the Bedrock embedding provider.
112#[derive(Clone)]
113pub struct BedrockEmbeddingConfig {
114    pub region: String,
115    pub model_id: String,
116    pub family: BedrockEmbeddingFamily,
117    pub dimensions: usize,
118    pub credentials: AwsCredentialSource,
119}
120
121impl BedrockEmbeddingConfig {
122    /// Any supported model. `dimensions` must match what the model returns.
123    pub fn new(
124        region: impl Into<String>,
125        model_id: impl Into<String>,
126        dimensions: usize,
127        credentials: AwsCredentialSource,
128    ) -> MenteResult<Self> {
129        let model_id = model_id.into();
130        Ok(Self {
131            region: region.into(),
132            family: BedrockEmbeddingFamily::for_model(&model_id)?,
133            model_id,
134            dimensions,
135            credentials,
136        })
137    }
138
139    /// A known model with its native dimensions, credentials from the
140    /// environment or the container role.
141    pub fn for_model(region: impl Into<String>, model_id: &str) -> MenteResult<Self> {
142        let dimensions = match model_id {
143            "amazon.titan-embed-text-v2:0" => 1024,
144            "amazon.titan-embed-text-v1" => 1536,
145            "cohere.embed-english-v3" | "cohere.embed-multilingual-v3" => 1024,
146            other => {
147                return Err(MenteError::Storage(format!(
148                    "unknown dimensions for Bedrock embedding model '{other}'; use \
149                     BedrockEmbeddingConfig::new with explicit dimensions"
150                )));
151            }
152        };
153        Self::new(
154            region,
155            model_id,
156            dimensions,
157            AwsCredentialSource::from_env()?,
158        )
159    }
160
161    /// Amazon Titan Text Embeddings V2 (1024 dimensions).
162    pub fn titan_v2(region: impl Into<String>) -> MenteResult<Self> {
163        Self::for_model(region, "amazon.titan-embed-text-v2:0")
164    }
165
166    /// Amazon Titan Text Embeddings V1 (1536 dimensions).
167    pub fn titan_v1(region: impl Into<String>) -> MenteResult<Self> {
168        Self::for_model(region, "amazon.titan-embed-text-v1")
169    }
170
171    /// Cohere Embed English v3 (1024 dimensions).
172    pub fn cohere_english_v3(region: impl Into<String>) -> MenteResult<Self> {
173        Self::for_model(region, "cohere.embed-english-v3")
174    }
175}
176
177/// Synchronous Bedrock embedding provider.
178pub struct BedrockEmbeddingProvider {
179    config: BedrockEmbeddingConfig,
180    agent: ureq::Agent,
181}
182
183impl BedrockEmbeddingProvider {
184    pub fn new(config: BedrockEmbeddingConfig) -> MenteResult<Self> {
185        Ok(Self {
186            config,
187            agent: ureq::Agent::new_with_defaults(),
188        })
189    }
190
191    /// Embed up to one call's worth of texts.
192    fn invoke(&self, texts: &[&str]) -> MenteResult<Vec<Vec<f32>>> {
193        let body = match self.config.family {
194            BedrockEmbeddingFamily::Titan => serde_json::json!({ "inputText": texts[0] }),
195            // Stored memories and queries are embedded the same way, so the
196            // document input type is used for both, as on the cloud platform.
197            BedrockEmbeddingFamily::Cohere => serde_json::json!({
198                "texts": texts,
199                "input_type": "search_document",
200            }),
201        };
202        let value = self.post(&body)?;
203        let vectors = match self.config.family {
204            BedrockEmbeddingFamily::Titan => {
205                let r: TitanResponse = serde_json::from_value(value).map_err(|e| {
206                    MenteError::Storage(format!("bedrock response parse failed: {e}"))
207                })?;
208                vec![r.embedding]
209            }
210            BedrockEmbeddingFamily::Cohere => {
211                let r: CohereResponse = serde_json::from_value(value).map_err(|e| {
212                    MenteError::Storage(format!("bedrock response parse failed: {e}"))
213                })?;
214                r.embeddings
215            }
216        };
217        if vectors.len() != texts.len() {
218            return Err(MenteError::Storage(format!(
219                "bedrock returned {} embeddings for {} texts",
220                vectors.len(),
221                texts.len()
222            )));
223        }
224        Ok(vectors)
225    }
226
227    /// Sign and send one InvokeModel request.
228    fn post(&self, body: &serde_json::Value) -> MenteResult<serde_json::Value> {
229        let cfg = &self.config;
230        let creds = cfg.credentials.credentials()?;
231        let host = format!("bedrock-runtime.{}.amazonaws.com", cfg.region);
232        // The request is sent with the raw model id in the path, while the SigV4
233        // canonical URI uses the percent-encoded form. AWS re-encodes the path it
234        // receives the same way, so signing the encoded form while sending the raw
235        // form matches, which is what the AWS SDKs do for model ids containing ':'.
236        let canonical_uri = format!("/model/{}/invoke", uri_encode_segment(&cfg.model_id));
237        let url = format!("https://{host}/model/{}/invoke", cfg.model_id);
238
239        let body =
240            serde_json::to_vec(body).map_err(|e| MenteError::Serialization(e.to_string()))?;
241        let payload_hash = sha256_hex(&body);
242
243        let now = chrono::Utc::now();
244        let amzdate = now.format("%Y%m%dT%H%M%SZ").to_string();
245        let datestamp = now.format("%Y%m%d").to_string();
246
247        // Canonical (sorted, lowercase) headers that we sign and send.
248        let mut headers: Vec<(String, String)> = vec![
249            ("host".to_string(), host.clone()),
250            ("x-amz-content-sha256".to_string(), payload_hash.clone()),
251            ("x-amz-date".to_string(), amzdate.clone()),
252        ];
253        if let Some(token) = &creds.session_token {
254            headers.push(("x-amz-security-token".to_string(), token.clone()));
255        }
256        headers.sort_by(|a, b| a.0.cmp(&b.0));
257        let canonical_headers: String = headers.iter().map(|(k, v)| format!("{k}:{v}\n")).collect();
258        let signed_headers = headers
259            .iter()
260            .map(|(k, _)| k.as_str())
261            .collect::<Vec<_>>()
262            .join(";");
263
264        let canonical_request = format!(
265            "POST\n{canonical_uri}\n\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
266        );
267        let scope = format!("{datestamp}/{}/{SERVICE}/aws4_request", cfg.region);
268        let string_to_sign = format!(
269            "AWS4-HMAC-SHA256\n{amzdate}\n{scope}\n{}",
270            sha256_hex(canonical_request.as_bytes())
271        );
272        let key = signing_key(&creds.secret_access_key, &datestamp, &cfg.region, SERVICE);
273        let signature = hex(&hmac_sha256(&key, string_to_sign.as_bytes()));
274        let authorization = format!(
275            "AWS4-HMAC-SHA256 Credential={}/{scope}, SignedHeaders={signed_headers}, Signature={signature}",
276            creds.access_key_id
277        );
278
279        let mut req = self
280            .agent
281            .post(&url)
282            .header("content-type", "application/json")
283            .header("accept", "application/json")
284            .header("x-amz-date", &amzdate)
285            .header("x-amz-content-sha256", &payload_hash)
286            .header("authorization", &authorization);
287        if let Some(token) = &creds.session_token {
288            req = req.header("x-amz-security-token", token);
289        }
290
291        let mut resp = req
292            .send(body.as_slice())
293            .map_err(|e| MenteError::Storage(format!("bedrock invoke failed: {e}")))?;
294        resp.body_mut()
295            .read_json()
296            .map_err(|e| MenteError::Storage(format!("bedrock response parse failed: {e}")))
297    }
298}
299
300#[derive(serde::Deserialize)]
301struct TitanResponse {
302    embedding: Vec<f32>,
303}
304
305#[derive(serde::Deserialize)]
306struct CohereResponse {
307    embeddings: Vec<Vec<f32>>,
308}
309
310impl EmbeddingProvider for BedrockEmbeddingProvider {
311    fn embed(&self, text: &str) -> MenteResult<Vec<f32>> {
312        self.invoke(&[text])?
313            .pop()
314            .ok_or_else(|| MenteError::Storage("bedrock returned no embedding".to_string()))
315    }
316
317    fn embed_batch(&self, texts: &[&str]) -> MenteResult<Vec<Vec<f32>>> {
318        let per_call = match self.config.family {
319            BedrockEmbeddingFamily::Titan => 1,
320            BedrockEmbeddingFamily::Cohere => COHERE_MAX_BATCH,
321        };
322        let mut out = Vec::with_capacity(texts.len());
323        for chunk in texts.chunks(per_call) {
324            out.extend(self.invoke(chunk)?);
325        }
326        Ok(out)
327    }
328
329    fn dimensions(&self) -> usize {
330        self.config.dimensions
331    }
332
333    fn model_name(&self) -> &str {
334        &self.config.model_id
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    /// Verify the SigV4 signing chain against AWS's published worked example
343    /// (docs: "Examples of the complete Signature Version 4 signing process").
344    /// This proves the HMAC key derivation and final signature are correct
345    /// without needing a live AWS call.
346    #[test]
347    fn sigv4_matches_aws_reference_vector() {
348        let secret = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";
349        let datestamp = "20150830";
350        let region = "us-east-1";
351        let service = "iam";
352        let string_to_sign = "AWS4-HMAC-SHA256\n20150830T123600Z\n\
353             20150830/us-east-1/iam/aws4_request\n\
354             f536975d06c0309214f805bb90ccff089219ecd68b2577efef23edd43b7e1a59";
355        let key = signing_key(secret, datestamp, region, service);
356        let signature = hex(&hmac_sha256(&key, string_to_sign.as_bytes()));
357        assert_eq!(
358            signature,
359            "5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7"
360        );
361    }
362
363    #[test]
364    fn model_id_path_segment_is_percent_encoded() {
365        // The ':' in a Bedrock model id must be encoded so the request URL and
366        // the signed canonical URI match.
367        assert_eq!(
368            uri_encode_segment("amazon.titan-embed-text-v2:0"),
369            "amazon.titan-embed-text-v2%3A0"
370        );
371    }
372
373    #[test]
374    fn model_family_follows_the_provider_prefix() {
375        use BedrockEmbeddingFamily::*;
376        assert_eq!(
377            BedrockEmbeddingFamily::for_model("amazon.titan-embed-text-v2:0").unwrap(),
378            Titan
379        );
380        assert_eq!(
381            BedrockEmbeddingFamily::for_model("cohere.embed-english-v3").unwrap(),
382            Cohere
383        );
384        assert_eq!(
385            BedrockEmbeddingFamily::for_model("us.cohere.embed-english-v3").unwrap(),
386            Cohere
387        );
388        assert!(BedrockEmbeddingFamily::for_model("anthropic.claude-haiku-5-5").is_err());
389    }
390
391    #[test]
392    fn sha256_hex_of_empty_is_known_constant() {
393        assert_eq!(
394            sha256_hex(b""),
395            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
396        );
397    }
398
399    /// Live end-to-end call against real Bedrock. Ignored by default (needs AWS
400    /// credentials in the environment). Run with:
401    ///   AWS_REGION=us-east-1 cargo test -p mentedb-embedding --features bedrock \
402    ///     live_titan_embed -- --ignored --nocapture
403    #[test]
404    #[ignore]
405    fn live_titan_embed() {
406        let region = std::env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string());
407        let cfg = BedrockEmbeddingConfig::titan_v2(region).expect("credentials from env");
408        let provider = BedrockEmbeddingProvider::new(cfg).expect("provider");
409        let v = provider.embed("the sky is blue").expect("live embed");
410        assert_eq!(v.len(), 1024);
411        assert!(v.iter().any(|&x| x != 0.0), "embedding should be non-zero");
412    }
413
414    /// Live Cohere batch call. Ignored by default, like `live_titan_embed`.
415    #[test]
416    #[ignore]
417    fn live_cohere_embed_batch() {
418        let region = std::env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string());
419        let cfg = BedrockEmbeddingConfig::cohere_english_v3(region).expect("credentials");
420        let provider = BedrockEmbeddingProvider::new(cfg).expect("provider");
421        let texts: Vec<String> = (0..100).map(|i| format!("memory number {i}")).collect();
422        let refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
423        let v = provider.embed_batch(&refs).expect("live batch");
424        assert_eq!(v.len(), 100, "100 texts span two calls of at most 96");
425        assert!(v.iter().all(|e| e.len() == 1024));
426    }
427}