1use crate::config::{ExtractionConfig, LlmProvider};
2use crate::error::ExtractionError;
3
4#[cfg(feature = "bedrock")]
12mod bedrock_sig {
13 use hmac::{Hmac, Mac};
14 use mentedb_embedding::AwsCredentials;
15 use sha2::{Digest, Sha256};
16
17 type HmacSha256 = Hmac<Sha256>;
18
19 const SERVICE: &str = "bedrock";
20
21 fn hex(bytes: &[u8]) -> String {
22 let mut s = String::with_capacity(bytes.len() * 2);
23 for b in bytes {
24 s.push_str(&format!("{b:02x}"));
25 }
26 s
27 }
28
29 fn sha256_hex(data: &[u8]) -> String {
30 hex(&Sha256::digest(data))
31 }
32
33 fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
34 let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
35 mac.update(data);
36 mac.finalize().into_bytes().to_vec()
37 }
38
39 fn signing_key(secret: &str, datestamp: &str, region: &str, service: &str) -> Vec<u8> {
41 let k_date = hmac_sha256(format!("AWS4{secret}").as_bytes(), datestamp.as_bytes());
42 let k_region = hmac_sha256(&k_date, region.as_bytes());
43 let k_service = hmac_sha256(&k_region, service.as_bytes());
44 hmac_sha256(&k_service, b"aws4_request")
45 }
46
47 fn uri_encode_segment(s: &str) -> String {
51 let mut out = String::with_capacity(s.len());
52 for b in s.bytes() {
53 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
54 out.push(b as char);
55 } else {
56 out.push_str(&format!("%{b:02X}"));
57 }
58 }
59 out
60 }
61
62 pub(super) struct SignedRequest {
64 pub url: String,
65 pub body: Vec<u8>,
66 pub headers: Vec<(&'static str, String)>,
70 }
71
72 pub(super) fn build_signed_request(
77 region: &str,
78 model: &str,
79 body: Vec<u8>,
80 creds: &AwsCredentials,
81 amzdate: &str,
82 datestamp: &str,
83 ) -> SignedRequest {
84 let host = format!("bedrock-runtime.{region}.amazonaws.com");
85 let canonical_uri = format!("/model/{}/invoke", uri_encode_segment(model));
89 let url = format!("https://{host}/model/{model}/invoke");
90
91 let payload_hash = sha256_hex(&body);
92
93 let mut signed: Vec<(String, String)> = vec![
94 ("host".to_string(), host.clone()),
95 ("x-amz-content-sha256".to_string(), payload_hash.clone()),
96 ("x-amz-date".to_string(), amzdate.to_string()),
97 ];
98 if let Some(token) = &creds.session_token {
99 signed.push(("x-amz-security-token".to_string(), token.clone()));
100 }
101 signed.sort_by(|a, b| a.0.cmp(&b.0));
102 let canonical_headers: String = signed.iter().map(|(k, v)| format!("{k}:{v}\n")).collect();
103 let signed_headers = signed
104 .iter()
105 .map(|(k, _)| k.as_str())
106 .collect::<Vec<_>>()
107 .join(";");
108
109 let canonical_request = format!(
110 "POST\n{canonical_uri}\n\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
111 );
112 let scope = format!("{datestamp}/{region}/{SERVICE}/aws4_request");
113 let string_to_sign = format!(
114 "AWS4-HMAC-SHA256\n{amzdate}\n{scope}\n{}",
115 sha256_hex(canonical_request.as_bytes())
116 );
117 let key = signing_key(&creds.secret_access_key, datestamp, region, SERVICE);
118 let signature = hex(&hmac_sha256(&key, string_to_sign.as_bytes()));
119 let authorization = format!(
120 "AWS4-HMAC-SHA256 Credential={}/{scope}, SignedHeaders={signed_headers}, Signature={signature}",
121 creds.access_key_id
122 );
123
124 let mut headers: Vec<(&'static str, String)> = vec![
125 ("Authorization", authorization),
126 ("X-Amz-Date", amzdate.to_string()),
127 ("X-Amz-Content-Sha256", payload_hash),
128 ];
129 if let Some(token) = &creds.session_token {
130 headers.push(("X-Amz-Security-Token", token.clone()));
131 }
132
133 SignedRequest { url, body, headers }
134 }
135}
136
137fn classify_api_error(
139 status: reqwest::StatusCode,
140 body: &str,
141 provider: &str,
142 model: &str,
143) -> ExtractionError {
144 let code = status.as_u16();
145 match code {
146 401 => ExtractionError::AuthError(format!(
147 "{provider} returned 401 Unauthorized. Check your API key (MENTEDB_LLM_API_KEY). \
148 Current provider: {provider}, model: {model}"
149 )),
150 403 => ExtractionError::AuthError(format!(
151 "{provider} returned 403 Forbidden. Your API key may lack permissions for model '{model}'."
152 )),
153 404 => ExtractionError::ModelNotFound(format!(
154 "{provider} returned 404. Model '{model}' may not exist or is not available on your account."
155 )),
156 _ => ExtractionError::ProviderError(format!("{provider} API returned {status}: {body}")),
157 }
158}
159
160pub trait ExtractionProvider: Send + Sync {
162 fn extract(
165 &self,
166 conversation: &str,
167 system_prompt: &str,
168 ) -> impl std::future::Future<Output = Result<String, ExtractionError>> + Send;
169}
170
171pub struct HttpExtractionProvider {
173 client: reqwest::Client,
174 config: ExtractionConfig,
175}
176
177impl HttpExtractionProvider {
178 pub fn new(config: ExtractionConfig) -> Result<Self, ExtractionError> {
179 let needs_api_key = !matches!(config.provider, LlmProvider::Ollama | LlmProvider::Bedrock);
183 if needs_api_key && config.api_key.is_none() {
184 return Err(ExtractionError::ConfigError(
185 "API key is required for this provider".to_string(),
186 ));
187 }
188 if config.provider == LlmProvider::Bedrock {
189 #[cfg(feature = "bedrock")]
190 {
191 mentedb_embedding::AwsCredentials::from_env().map_err(|e| {
194 ExtractionError::ConfigError(format!(
195 "Bedrock requires AWS credentials in the environment \
196 (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, and \
197 AWS_SESSION_TOKEN for temporary/SSO credentials): {e}"
198 ))
199 })?;
200 }
201 #[cfg(not(feature = "bedrock"))]
202 {
203 return Err(ExtractionError::ConfigError(
204 "bedrock support not compiled in (build with --features bedrock)".to_string(),
205 ));
206 }
207 }
208 let client = reqwest::Client::builder()
209 .timeout(std::time::Duration::from_secs(120))
210 .connect_timeout(std::time::Duration::from_secs(30))
211 .build()
212 .map_err(|e| ExtractionError::ConfigError(format!("HTTP client error: {}", e)))?;
213 Ok(Self { client, config })
214 }
215
216 pub async fn expand_query(&self, query: &str) -> Result<Vec<String>, ExtractionError> {
226 let system_prompt = "You help search a memory database. Given a question, return a JSON object with:\n\
227 - \"answer_type\": one of PLACE, DATE, TIME, NUMBER, NAME, PERSON, BRAND, ITEM, ACTIVITY, COUNTING, OTHER\n\
228 - \"queries\": array of 2-3 short search queries\n\
229 - For COUNTING only, also include:\n\
230 - \"item_keywords\": comma-separated specific subtypes/instances that would be individually counted\n\
231 - \"broad_keywords\": comma-separated category terms, action verbs, and general synonyms\n\n\
232 Use COUNTING when the question requires COMPLETENESS — counting, listing, aggregating, totaling, \
233 or comparing to find a superlative (most, least, best, worst, first, last, biggest, highest, lowest).\n\n\
234 The distinction matters:\n\
235 - item_keywords: specific things you would COUNT (types of the thing being asked about)\n\
236 - broad_keywords: general terms that help FIND memories but aren't counted themselves\n\n\
237 Examples:\n\
238 Q: \"Where do I take yoga classes?\"\n\
239 {\"answer_type\": \"PLACE\", \"queries\": [\"yoga studio name\", \"yoga class location\"]}\n\n\
240 Q: \"How many doctors did I visit?\"\n\
241 {\"answer_type\": \"COUNTING\", \"queries\": [\"doctor visits appointments\", \"medical specialist visits\"], \
242 \"item_keywords\": \"doctor, Dr., physician, specialist, dermatologist, cardiologist, dentist, surgeon, pediatrician, orthopedist, ophthalmologist\", \
243 \"broad_keywords\": \"medical, clinic, appointment, visit, diagnosed, prescribed, referred, checkup, exam\"}\n\n\
244 Q: \"Which platform did I gain the most followers on?\"\n\
245 {\"answer_type\": \"COUNTING\", \"queries\": [\"social media follower growth\", \"follower count increase\"], \
246 \"item_keywords\": \"TikTok, Instagram, Twitter, YouTube, Facebook, LinkedIn, Snapchat, Reddit, Twitch\", \
247 \"broad_keywords\": \"followers, follower count, gained, growth, platform, social media, increase, jumped, grew\"}";
248 let result = self.call_with_retry(query, system_prompt).await?;
249
250 let mut lines: Vec<String> = Vec::new();
252 let cleaned = result
253 .trim()
254 .trim_start_matches("```json")
255 .trim_end_matches("```")
256 .trim();
257 if let Ok(json) = serde_json::from_str::<serde_json::Value>(cleaned) {
258 if let Some(answer_type) = json.get("answer_type").and_then(|v| v.as_str()) {
259 lines.push(answer_type.to_string());
260 }
261 if let Some(queries) = json.get("queries").and_then(|v| v.as_array()) {
262 for q in queries {
263 if let Some(s) = q.as_str() {
264 lines.push(s.to_string());
265 }
266 }
267 }
268 if let Some(item_kw) = json.get("item_keywords").and_then(|v| v.as_str()) {
269 lines.push(format!("ITEM_KEYWORDS: {}", item_kw));
270 }
271 if let Some(broad_kw) = json.get("broad_keywords").and_then(|v| v.as_str()) {
272 lines.push(format!("BROAD_KEYWORDS: {}", broad_kw));
273 }
274 if let Some(keywords) = json.get("keywords").and_then(|v| v.as_str())
276 && json.get("item_keywords").is_none()
277 {
278 lines.push(format!("ITEM_KEYWORDS: {}", keywords));
279 }
280 } else {
281 lines = result
283 .lines()
284 .map(|l| l.trim().to_string())
285 .filter(|l| !l.is_empty())
286 .collect();
287 }
288 if std::env::var("MENTEDB_DEBUG").is_ok() {
289 eprintln!("[expand_query] input={:?} parsed={:?}", query, lines);
290 }
291 Ok(lines)
292 }
293
294 async fn call_openai(
295 &self,
296 conversation: &str,
297 system_prompt: &str,
298 ) -> Result<String, ExtractionError> {
299 let body = serde_json::json!({
300 "model": self.config.model,
301 "temperature": 0,
302 "response_format": { "type": "json_object" },
303 "messages": [
304 { "role": "system", "content": system_prompt },
305 { "role": "user", "content": conversation }
306 ]
307 });
308
309 let api_key = self.config.api_key.as_deref().unwrap_or_default();
310
311 let resp = self
312 .client
313 .post(&self.config.api_url)
314 .header("Authorization", format!("Bearer {api_key}"))
315 .header("Content-Type", "application/json")
316 .json(&body)
317 .send()
318 .await?;
319
320 let status = resp.status();
321 let text = resp.text().await?;
322
323 if !status.is_success() {
324 return Err(classify_api_error(
325 status,
326 &text,
327 "OpenAI",
328 &self.config.model,
329 ));
330 }
331
332 let parsed: serde_json::Value = serde_json::from_str(&text)?;
333 parsed["choices"][0]["message"]["content"]
334 .as_str()
335 .map(|s| s.to_string())
336 .ok_or_else(|| {
337 ExtractionError::ParseError("Missing content in OpenAI response".to_string())
338 })
339 }
340
341 async fn call_openai_text(
344 &self,
345 conversation: &str,
346 system_prompt: &str,
347 ) -> Result<String, ExtractionError> {
348 let body = serde_json::json!({
349 "model": self.config.model,
350 "temperature": 0,
351 "messages": [
352 { "role": "system", "content": system_prompt },
353 { "role": "user", "content": conversation }
354 ]
355 });
356
357 let api_key = self.config.api_key.as_deref().unwrap_or_default();
358
359 let resp = self
360 .client
361 .post(&self.config.api_url)
362 .header("Authorization", format!("Bearer {api_key}"))
363 .header("Content-Type", "application/json")
364 .json(&body)
365 .send()
366 .await?;
367
368 let status = resp.status();
369 let text = resp.text().await?;
370
371 if !status.is_success() {
372 return Err(classify_api_error(
373 status,
374 &text,
375 "OpenAI",
376 &self.config.model,
377 ));
378 }
379
380 let parsed: serde_json::Value = serde_json::from_str(&text)?;
381 parsed["choices"][0]["message"]["content"]
382 .as_str()
383 .map(|s| s.to_string())
384 .ok_or_else(|| {
385 ExtractionError::ParseError("Missing content in OpenAI response".to_string())
386 })
387 }
388
389 async fn call_anthropic(
390 &self,
391 conversation: &str,
392 system_prompt: &str,
393 ) -> Result<String, ExtractionError> {
394 let body = serde_json::json!({
395 "model": self.config.model,
396 "max_tokens": 4096,
397 "temperature": 0,
398 "system": system_prompt,
399 "messages": [
400 { "role": "user", "content": conversation }
401 ]
402 });
403
404 let api_key = self.config.api_key.as_deref().unwrap_or_default();
405
406 let resp = self
407 .client
408 .post(&self.config.api_url)
409 .header("x-api-key", api_key)
410 .header("anthropic-version", "2023-06-01")
411 .header("Content-Type", "application/json")
412 .json(&body)
413 .send()
414 .await?;
415
416 let status = resp.status();
417 let text = resp.text().await?;
418
419 if !status.is_success() {
420 return Err(classify_api_error(
421 status,
422 &text,
423 "Anthropic",
424 &self.config.model,
425 ));
426 }
427
428 let parsed: serde_json::Value = serde_json::from_str(&text)?;
429
430 let content_text = parsed["content"]
432 .as_array()
433 .and_then(|blocks| {
434 blocks.iter().find_map(|block| {
435 if block["type"].as_str() == Some("text") {
436 block["text"].as_str().map(|s| s.to_string())
437 } else {
438 None
439 }
440 })
441 })
442 .or_else(|| {
443 parsed["content"][0]["text"].as_str().map(|s| s.to_string())
445 });
446
447 match content_text {
448 Some(t) if !t.trim().is_empty() => Ok(t),
449 Some(_) => {
450 tracing::warn!(
451 model = %self.config.model,
452 "Anthropic returned empty text content"
453 );
454 Ok("{\"memories\": []}".to_string())
455 }
456 None => {
457 tracing::warn!(
458 model = %self.config.model,
459 response_preview =
460 mentedb_core::text::truncate_on_char_boundary(&text, 300),
461 "No text block found in Anthropic response"
462 );
463 Ok("{\"memories\": []}".to_string())
464 }
465 }
466 }
467
468 #[cfg(feature = "bedrock")]
475 async fn call_bedrock(
476 &self,
477 conversation: &str,
478 system_prompt: &str,
479 ) -> Result<String, ExtractionError> {
480 let region = self
481 .config
482 .region
483 .clone()
484 .unwrap_or_else(crate::config::default_bedrock_region);
485
486 let body_json = serde_json::json!({
487 "anthropic_version": "bedrock-2023-05-31",
488 "max_tokens": 4096,
489 "system": system_prompt,
490 "messages": [
491 { "role": "user", "content": conversation }
492 ]
493 });
494 let body = serde_json::to_vec(&body_json)?;
495
496 let creds = mentedb_embedding::AwsCredentials::from_env().map_err(|e| {
497 ExtractionError::ConfigError(format!(
498 "Bedrock requires AWS credentials in the environment \
499 (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN \
500 for temporary/SSO credentials): {e}"
501 ))
502 })?;
503
504 let now = chrono::Utc::now();
505 let amzdate = now.format("%Y%m%dT%H%M%SZ").to_string();
506 let datestamp = now.format("%Y%m%d").to_string();
507
508 let signed = bedrock_sig::build_signed_request(
509 ®ion,
510 &self.config.model,
511 body,
512 &creds,
513 &amzdate,
514 &datestamp,
515 );
516
517 let mut req = self
518 .client
519 .post(&signed.url)
520 .header("Content-Type", "application/json")
521 .header("Accept", "application/json");
522 for (name, value) in &signed.headers {
523 req = req.header(*name, value);
524 }
525
526 let resp = req.body(signed.body).send().await?;
527
528 let status = resp.status();
529 let text = resp.text().await?;
530
531 if !status.is_success() {
532 return Err(classify_api_error(
533 status,
534 &text,
535 "Bedrock",
536 &self.config.model,
537 ));
538 }
539
540 let parsed: serde_json::Value = serde_json::from_str(&text)?;
541
542 let content_text: String = parsed["content"]
545 .as_array()
546 .map(|blocks| {
547 blocks
548 .iter()
549 .filter(|block| block["type"].as_str() == Some("text"))
550 .filter_map(|block| block["text"].as_str())
551 .collect::<Vec<_>>()
552 .join("")
553 })
554 .unwrap_or_default();
555
556 if content_text.trim().is_empty() {
557 tracing::warn!(
558 model = %self.config.model,
559 response_preview =
560 mentedb_core::text::truncate_on_char_boundary(&text, 300),
561 "No text block found in Bedrock response"
562 );
563 return Ok("{\"memories\": []}".to_string());
564 }
565 Ok(content_text)
566 }
567
568 #[cfg(not(feature = "bedrock"))]
572 async fn call_bedrock(
573 &self,
574 _conversation: &str,
575 _system_prompt: &str,
576 ) -> Result<String, ExtractionError> {
577 Err(ExtractionError::ConfigError(
578 "bedrock support not compiled in (build with --features bedrock)".to_string(),
579 ))
580 }
581
582 async fn call_ollama(
583 &self,
584 conversation: &str,
585 system_prompt: &str,
586 ) -> Result<String, ExtractionError> {
587 let body = serde_json::json!({
588 "model": self.config.model,
589 "stream": false,
590 "format": "json",
591 "messages": [
592 { "role": "system", "content": system_prompt },
593 { "role": "user", "content": conversation }
594 ]
595 });
596
597 let resp = self
598 .client
599 .post(&self.config.api_url)
600 .header("Content-Type", "application/json")
601 .json(&body)
602 .send()
603 .await?;
604
605 let status = resp.status();
606 let text = resp.text().await?;
607
608 if !status.is_success() {
609 return Err(classify_api_error(
610 status,
611 &text,
612 "Ollama",
613 &self.config.model,
614 ));
615 }
616
617 let parsed: serde_json::Value = serde_json::from_str(&text)?;
618 parsed["message"]["content"]
619 .as_str()
620 .map(|s| s.to_string())
621 .ok_or_else(|| {
622 ExtractionError::ParseError("Missing content in Ollama response".to_string())
623 })
624 }
625
626 pub async fn call_with_retry(
629 &self,
630 conversation: &str,
631 system_prompt: &str,
632 ) -> Result<String, ExtractionError> {
633 self.call_with_retry_inner(conversation, system_prompt, true)
634 .await
635 }
636
637 pub async fn call_text_with_retry(
640 &self,
641 conversation: &str,
642 system_prompt: &str,
643 ) -> Result<String, ExtractionError> {
644 self.call_with_retry_inner(conversation, system_prompt, false)
645 .await
646 }
647
648 async fn call_with_retry_inner(
649 &self,
650 conversation: &str,
651 system_prompt: &str,
652 force_json: bool,
653 ) -> Result<String, ExtractionError> {
654 let max_attempts = 3;
655 let mut last_err = None;
656
657 for attempt in 0..max_attempts {
658 if attempt > 0 {
659 let delay = std::time::Duration::from_secs(1 << attempt);
660 tracing::warn!(
661 attempt,
662 delay_secs = delay.as_secs(),
663 "retrying after rate limit"
664 );
665 tokio::time::sleep(delay).await;
666 }
667
668 tracing::info!(
669 provider = ?self.config.provider,
670 model = %self.config.model,
671 attempt = attempt + 1,
672 "calling LLM extraction API"
673 );
674
675 let result = match self.config.provider {
676 LlmProvider::OpenAI | LlmProvider::Custom => {
677 if force_json {
678 self.call_openai(conversation, system_prompt).await
679 } else {
680 self.call_openai_text(conversation, system_prompt).await
681 }
682 }
683 LlmProvider::Anthropic => self.call_anthropic(conversation, system_prompt).await,
684 LlmProvider::Bedrock => self.call_bedrock(conversation, system_prompt).await,
687 LlmProvider::Ollama => self.call_ollama(conversation, system_prompt).await,
688 };
689
690 match result {
691 Ok(text) => {
692 tracing::info!(response_len = text.len(), "LLM extraction complete");
693 return Ok(text);
694 }
695 Err(ExtractionError::ProviderError(ref msg))
696 if msg.contains("429")
697 || msg.contains("500")
698 || msg.contains("502")
699 || msg.contains("503")
700 || msg.contains("529")
701 || msg.contains("timeout")
702 || msg.contains("connection")
703 || msg.contains("overloaded") =>
704 {
705 tracing::warn!(attempt = attempt + 1, error = %msg, "retrying transient LLM error");
706 last_err = Some(result.unwrap_err());
707 continue;
708 }
709 Err(e) => {
710 tracing::error!(error = %e, "LLM extraction failed (non-retryable)");
711 return Err(e);
712 }
713 }
714 }
715
716 match last_err {
717 Some(e) => Err(e),
718 None => Err(ExtractionError::RateLimitExceeded {
719 attempts: max_attempts,
720 }),
721 }
722 }
723}
724
725impl ExtractionProvider for HttpExtractionProvider {
726 async fn extract(
727 &self,
728 conversation: &str,
729 system_prompt: &str,
730 ) -> Result<String, ExtractionError> {
731 self.call_with_retry(conversation, system_prompt).await
732 }
733}
734
735pub struct MockExtractionProvider {
737 response: String,
738}
739
740impl MockExtractionProvider {
741 pub fn new(response: impl Into<String>) -> Self {
743 Self {
744 response: response.into(),
745 }
746 }
747
748 pub fn with_realistic_response() -> Self {
750 let response = serde_json::json!({
751 "memories": [
752 {
753 "content": "The team decided to use PostgreSQL 15 as the primary database for the REST API project",
754 "memory_type": "decision",
755 "confidence": 0.95,
756 "entities": ["PostgreSQL", "REST API"],
757 "tags": ["database", "architecture"],
758 "reasoning": "Explicitly decided after comparing options"
759 },
760 {
761 "content": "REST endpoints should follow the /api/v1/ prefix convention",
762 "memory_type": "decision",
763 "confidence": 0.9,
764 "entities": ["REST API"],
765 "tags": ["api-design", "conventions"],
766 "reasoning": "Team agreed on URL structure"
767 },
768 {
769 "content": "User prefers Rust over Go for backend services due to memory safety guarantees",
770 "memory_type": "preference",
771 "confidence": 0.85,
772 "entities": ["Rust", "Go"],
773 "tags": ["language", "backend"],
774 "reasoning": "Explicitly stated preference with clear reasoning"
775 },
776 {
777 "content": "The initial plan to use MongoDB was incorrect; PostgreSQL is the right choice for relational data",
778 "memory_type": "correction",
779 "confidence": 0.9,
780 "entities": ["MongoDB", "PostgreSQL"],
781 "tags": ["database", "correction"],
782 "reasoning": "Corrected an earlier wrong assumption"
783 },
784 {
785 "content": "The project deadline is March 15, 2025",
786 "memory_type": "fact",
787 "confidence": 0.8,
788 "entities": ["REST API project"],
789 "tags": ["timeline"],
790 "reasoning": "Confirmed date mentioned in discussion"
791 },
792 {
793 "content": "Using global mutable state for database connections caused race conditions in testing",
794 "memory_type": "anti_pattern",
795 "confidence": 0.85,
796 "entities": [],
797 "tags": ["testing", "concurrency"],
798 "reasoning": "Documented failure pattern to avoid repeating"
799 },
800 {
801 "content": "Low confidence speculation about maybe using Redis",
802 "memory_type": "fact",
803 "confidence": 0.3,
804 "entities": ["Redis"],
805 "tags": ["cache"],
806 "reasoning": "Mentioned but not confirmed"
807 }
808 ]
809 });
810 Self::new(response.to_string())
811 }
812}
813
814impl ExtractionProvider for MockExtractionProvider {
815 async fn extract(
816 &self,
817 _conversation: &str,
818 _system_prompt: &str,
819 ) -> Result<String, ExtractionError> {
820 Ok(self.response.clone())
821 }
822}
823
824#[cfg(all(test, feature = "bedrock"))]
825mod bedrock_tests {
826 use super::*;
827 use mentedb_embedding::AwsCredentials;
828
829 fn bedrock_body(system: &str, user: &str) -> Vec<u8> {
832 let body_json = serde_json::json!({
833 "anthropic_version": "bedrock-2023-05-31",
834 "max_tokens": 4096,
835 "system": system,
836 "messages": [
837 { "role": "user", "content": user }
838 ]
839 });
840 serde_json::to_vec(&body_json).unwrap()
841 }
842
843 #[test]
849 fn signed_bedrock_request_has_expected_url_body_and_auth() {
850 let creds = AwsCredentials {
851 access_key_id: "AKIDEXAMPLE".to_string(),
852 secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(),
853 session_token: None,
854 };
855 let region = "us-east-1";
856 let model = "us.anthropic.claude-haiku-4-5";
857 let system = "You extract memories.";
858 let user = "I switched my database to PostgreSQL.";
859 let body = bedrock_body(system, user);
860
861 let signed = bedrock_sig::build_signed_request(
862 region,
863 model,
864 body,
865 &creds,
866 "20150830T123600Z",
867 "20150830",
868 );
869
870 assert_eq!(
872 signed.url,
873 "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5/invoke"
874 );
875
876 let parsed: serde_json::Value = serde_json::from_slice(&signed.body).unwrap();
878 assert_eq!(parsed["anthropic_version"], "bedrock-2023-05-31");
879 assert_eq!(parsed["max_tokens"], 4096);
880 assert_eq!(parsed["system"], system);
881 assert_eq!(parsed["messages"][0]["role"], "user");
882 assert_eq!(parsed["messages"][0]["content"], user);
883
884 let auth = signed
886 .headers
887 .iter()
888 .find(|(k, _)| *k == "Authorization")
889 .map(|(_, v)| v.as_str())
890 .expect("Authorization header present");
891 assert!(
892 auth.starts_with("AWS4-HMAC-SHA256 "),
893 "unexpected auth scheme: {auth}"
894 );
895 assert!(auth.contains("Credential=AKIDEXAMPLE/20150830/us-east-1/bedrock/aws4_request"));
896 assert!(auth.contains("SignedHeaders=host;x-amz-content-sha256;x-amz-date"));
897 assert!(auth.contains("Signature="));
898
899 assert!(
901 signed
902 .headers
903 .iter()
904 .any(|(k, v)| *k == "X-Amz-Date" && v == "20150830T123600Z")
905 );
906 assert!(
907 !signed
908 .headers
909 .iter()
910 .any(|(k, _)| *k == "X-Amz-Security-Token")
911 );
912 }
913
914 #[test]
917 fn session_token_adds_security_token_header() {
918 let creds = AwsCredentials {
919 access_key_id: "AKIDEXAMPLE".to_string(),
920 secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(),
921 session_token: Some("FQoGZQ-token".to_string()),
922 };
923 let signed = bedrock_sig::build_signed_request(
924 "us-west-2",
925 "us.anthropic.claude-sonnet-4-6",
926 bedrock_body("sys", "usr"),
927 &creds,
928 "20150830T123600Z",
929 "20150830",
930 );
931
932 let token = signed
933 .headers
934 .iter()
935 .find(|(k, _)| *k == "X-Amz-Security-Token")
936 .map(|(_, v)| v.as_str());
937 assert_eq!(token, Some("FQoGZQ-token"));
938
939 let auth = signed
940 .headers
941 .iter()
942 .find(|(k, _)| *k == "Authorization")
943 .map(|(_, v)| v.as_str())
944 .unwrap();
945 assert!(auth.contains("x-amz-security-token"));
947 assert!(
949 signed
950 .url
951 .starts_with("https://bedrock-runtime.us-west-2.amazonaws.com/")
952 );
953 }
954
955 #[test]
958 fn bedrock_config_defaults() {
959 let cfg = ExtractionConfig::bedrock("eu-central-1");
960 assert_eq!(cfg.provider, LlmProvider::Bedrock);
961 assert!(cfg.api_key.is_none());
962 assert_eq!(cfg.region.as_deref(), Some("eu-central-1"));
963 assert_eq!(cfg.model, "us.anthropic.claude-haiku-4-5");
964 assert_eq!(LlmProvider::Bedrock.default_url(), "");
965 assert_eq!(
966 LlmProvider::Bedrock.default_reader_model(),
967 "us.anthropic.claude-sonnet-4-6"
968 );
969 }
970}