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 = &text[..text.len().min(300)],
460 "No text block found in Anthropic response"
461 );
462 Ok("{\"memories\": []}".to_string())
463 }
464 }
465 }
466
467 #[cfg(feature = "bedrock")]
474 async fn call_bedrock(
475 &self,
476 conversation: &str,
477 system_prompt: &str,
478 ) -> Result<String, ExtractionError> {
479 let region = self
480 .config
481 .region
482 .clone()
483 .unwrap_or_else(crate::config::default_bedrock_region);
484
485 let body_json = serde_json::json!({
486 "anthropic_version": "bedrock-2023-05-31",
487 "max_tokens": 4096,
488 "system": system_prompt,
489 "messages": [
490 { "role": "user", "content": conversation }
491 ]
492 });
493 let body = serde_json::to_vec(&body_json)?;
494
495 let creds = mentedb_embedding::AwsCredentials::from_env().map_err(|e| {
496 ExtractionError::ConfigError(format!(
497 "Bedrock requires AWS credentials in the environment \
498 (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN \
499 for temporary/SSO credentials): {e}"
500 ))
501 })?;
502
503 let now = chrono::Utc::now();
504 let amzdate = now.format("%Y%m%dT%H%M%SZ").to_string();
505 let datestamp = now.format("%Y%m%d").to_string();
506
507 let signed = bedrock_sig::build_signed_request(
508 ®ion,
509 &self.config.model,
510 body,
511 &creds,
512 &amzdate,
513 &datestamp,
514 );
515
516 let mut req = self
517 .client
518 .post(&signed.url)
519 .header("Content-Type", "application/json")
520 .header("Accept", "application/json");
521 for (name, value) in &signed.headers {
522 req = req.header(*name, value);
523 }
524
525 let resp = req.body(signed.body).send().await?;
526
527 let status = resp.status();
528 let text = resp.text().await?;
529
530 if !status.is_success() {
531 return Err(classify_api_error(
532 status,
533 &text,
534 "Bedrock",
535 &self.config.model,
536 ));
537 }
538
539 let parsed: serde_json::Value = serde_json::from_str(&text)?;
540
541 let content_text: String = parsed["content"]
544 .as_array()
545 .map(|blocks| {
546 blocks
547 .iter()
548 .filter(|block| block["type"].as_str() == Some("text"))
549 .filter_map(|block| block["text"].as_str())
550 .collect::<Vec<_>>()
551 .join("")
552 })
553 .unwrap_or_default();
554
555 if content_text.trim().is_empty() {
556 tracing::warn!(
557 model = %self.config.model,
558 response_preview = &text[..text.len().min(300)],
559 "No text block found in Bedrock response"
560 );
561 return Ok("{\"memories\": []}".to_string());
562 }
563 Ok(content_text)
564 }
565
566 #[cfg(not(feature = "bedrock"))]
570 async fn call_bedrock(
571 &self,
572 _conversation: &str,
573 _system_prompt: &str,
574 ) -> Result<String, ExtractionError> {
575 Err(ExtractionError::ConfigError(
576 "bedrock support not compiled in (build with --features bedrock)".to_string(),
577 ))
578 }
579
580 async fn call_ollama(
581 &self,
582 conversation: &str,
583 system_prompt: &str,
584 ) -> Result<String, ExtractionError> {
585 let body = serde_json::json!({
586 "model": self.config.model,
587 "stream": false,
588 "format": "json",
589 "messages": [
590 { "role": "system", "content": system_prompt },
591 { "role": "user", "content": conversation }
592 ]
593 });
594
595 let resp = self
596 .client
597 .post(&self.config.api_url)
598 .header("Content-Type", "application/json")
599 .json(&body)
600 .send()
601 .await?;
602
603 let status = resp.status();
604 let text = resp.text().await?;
605
606 if !status.is_success() {
607 return Err(classify_api_error(
608 status,
609 &text,
610 "Ollama",
611 &self.config.model,
612 ));
613 }
614
615 let parsed: serde_json::Value = serde_json::from_str(&text)?;
616 parsed["message"]["content"]
617 .as_str()
618 .map(|s| s.to_string())
619 .ok_or_else(|| {
620 ExtractionError::ParseError("Missing content in Ollama response".to_string())
621 })
622 }
623
624 pub async fn call_with_retry(
627 &self,
628 conversation: &str,
629 system_prompt: &str,
630 ) -> Result<String, ExtractionError> {
631 self.call_with_retry_inner(conversation, system_prompt, true)
632 .await
633 }
634
635 pub async fn call_text_with_retry(
638 &self,
639 conversation: &str,
640 system_prompt: &str,
641 ) -> Result<String, ExtractionError> {
642 self.call_with_retry_inner(conversation, system_prompt, false)
643 .await
644 }
645
646 async fn call_with_retry_inner(
647 &self,
648 conversation: &str,
649 system_prompt: &str,
650 force_json: bool,
651 ) -> Result<String, ExtractionError> {
652 let max_attempts = 3;
653 let mut last_err = None;
654
655 for attempt in 0..max_attempts {
656 if attempt > 0 {
657 let delay = std::time::Duration::from_secs(1 << attempt);
658 tracing::warn!(
659 attempt,
660 delay_secs = delay.as_secs(),
661 "retrying after rate limit"
662 );
663 tokio::time::sleep(delay).await;
664 }
665
666 tracing::info!(
667 provider = ?self.config.provider,
668 model = %self.config.model,
669 attempt = attempt + 1,
670 "calling LLM extraction API"
671 );
672
673 let result = match self.config.provider {
674 LlmProvider::OpenAI | LlmProvider::Custom => {
675 if force_json {
676 self.call_openai(conversation, system_prompt).await
677 } else {
678 self.call_openai_text(conversation, system_prompt).await
679 }
680 }
681 LlmProvider::Anthropic => self.call_anthropic(conversation, system_prompt).await,
682 LlmProvider::Bedrock => self.call_bedrock(conversation, system_prompt).await,
685 LlmProvider::Ollama => self.call_ollama(conversation, system_prompt).await,
686 };
687
688 match result {
689 Ok(text) => {
690 tracing::info!(response_len = text.len(), "LLM extraction complete");
691 return Ok(text);
692 }
693 Err(ExtractionError::ProviderError(ref msg))
694 if msg.contains("429")
695 || msg.contains("500")
696 || msg.contains("502")
697 || msg.contains("503")
698 || msg.contains("529")
699 || msg.contains("timeout")
700 || msg.contains("connection")
701 || msg.contains("overloaded") =>
702 {
703 tracing::warn!(attempt = attempt + 1, error = %msg, "retrying transient LLM error");
704 last_err = Some(result.unwrap_err());
705 continue;
706 }
707 Err(e) => {
708 tracing::error!(error = %e, "LLM extraction failed (non-retryable)");
709 return Err(e);
710 }
711 }
712 }
713
714 match last_err {
715 Some(e) => Err(e),
716 None => Err(ExtractionError::RateLimitExceeded {
717 attempts: max_attempts,
718 }),
719 }
720 }
721}
722
723impl ExtractionProvider for HttpExtractionProvider {
724 async fn extract(
725 &self,
726 conversation: &str,
727 system_prompt: &str,
728 ) -> Result<String, ExtractionError> {
729 self.call_with_retry(conversation, system_prompt).await
730 }
731}
732
733pub struct MockExtractionProvider {
735 response: String,
736}
737
738impl MockExtractionProvider {
739 pub fn new(response: impl Into<String>) -> Self {
741 Self {
742 response: response.into(),
743 }
744 }
745
746 pub fn with_realistic_response() -> Self {
748 let response = serde_json::json!({
749 "memories": [
750 {
751 "content": "The team decided to use PostgreSQL 15 as the primary database for the REST API project",
752 "memory_type": "decision",
753 "confidence": 0.95,
754 "entities": ["PostgreSQL", "REST API"],
755 "tags": ["database", "architecture"],
756 "reasoning": "Explicitly decided after comparing options"
757 },
758 {
759 "content": "REST endpoints should follow the /api/v1/ prefix convention",
760 "memory_type": "decision",
761 "confidence": 0.9,
762 "entities": ["REST API"],
763 "tags": ["api-design", "conventions"],
764 "reasoning": "Team agreed on URL structure"
765 },
766 {
767 "content": "User prefers Rust over Go for backend services due to memory safety guarantees",
768 "memory_type": "preference",
769 "confidence": 0.85,
770 "entities": ["Rust", "Go"],
771 "tags": ["language", "backend"],
772 "reasoning": "Explicitly stated preference with clear reasoning"
773 },
774 {
775 "content": "The initial plan to use MongoDB was incorrect; PostgreSQL is the right choice for relational data",
776 "memory_type": "correction",
777 "confidence": 0.9,
778 "entities": ["MongoDB", "PostgreSQL"],
779 "tags": ["database", "correction"],
780 "reasoning": "Corrected an earlier wrong assumption"
781 },
782 {
783 "content": "The project deadline is March 15, 2025",
784 "memory_type": "fact",
785 "confidence": 0.8,
786 "entities": ["REST API project"],
787 "tags": ["timeline"],
788 "reasoning": "Confirmed date mentioned in discussion"
789 },
790 {
791 "content": "Using global mutable state for database connections caused race conditions in testing",
792 "memory_type": "anti_pattern",
793 "confidence": 0.85,
794 "entities": [],
795 "tags": ["testing", "concurrency"],
796 "reasoning": "Documented failure pattern to avoid repeating"
797 },
798 {
799 "content": "Low confidence speculation about maybe using Redis",
800 "memory_type": "fact",
801 "confidence": 0.3,
802 "entities": ["Redis"],
803 "tags": ["cache"],
804 "reasoning": "Mentioned but not confirmed"
805 }
806 ]
807 });
808 Self::new(response.to_string())
809 }
810}
811
812impl ExtractionProvider for MockExtractionProvider {
813 async fn extract(
814 &self,
815 _conversation: &str,
816 _system_prompt: &str,
817 ) -> Result<String, ExtractionError> {
818 Ok(self.response.clone())
819 }
820}
821
822#[cfg(all(test, feature = "bedrock"))]
823mod bedrock_tests {
824 use super::*;
825 use mentedb_embedding::AwsCredentials;
826
827 fn bedrock_body(system: &str, user: &str) -> Vec<u8> {
830 let body_json = serde_json::json!({
831 "anthropic_version": "bedrock-2023-05-31",
832 "max_tokens": 4096,
833 "system": system,
834 "messages": [
835 { "role": "user", "content": user }
836 ]
837 });
838 serde_json::to_vec(&body_json).unwrap()
839 }
840
841 #[test]
847 fn signed_bedrock_request_has_expected_url_body_and_auth() {
848 let creds = AwsCredentials {
849 access_key_id: "AKIDEXAMPLE".to_string(),
850 secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(),
851 session_token: None,
852 };
853 let region = "us-east-1";
854 let model = "us.anthropic.claude-haiku-4-5";
855 let system = "You extract memories.";
856 let user = "I switched my database to PostgreSQL.";
857 let body = bedrock_body(system, user);
858
859 let signed = bedrock_sig::build_signed_request(
860 region,
861 model,
862 body,
863 &creds,
864 "20150830T123600Z",
865 "20150830",
866 );
867
868 assert_eq!(
870 signed.url,
871 "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5/invoke"
872 );
873
874 let parsed: serde_json::Value = serde_json::from_slice(&signed.body).unwrap();
876 assert_eq!(parsed["anthropic_version"], "bedrock-2023-05-31");
877 assert_eq!(parsed["max_tokens"], 4096);
878 assert_eq!(parsed["system"], system);
879 assert_eq!(parsed["messages"][0]["role"], "user");
880 assert_eq!(parsed["messages"][0]["content"], user);
881
882 let auth = signed
884 .headers
885 .iter()
886 .find(|(k, _)| *k == "Authorization")
887 .map(|(_, v)| v.as_str())
888 .expect("Authorization header present");
889 assert!(
890 auth.starts_with("AWS4-HMAC-SHA256 "),
891 "unexpected auth scheme: {auth}"
892 );
893 assert!(auth.contains("Credential=AKIDEXAMPLE/20150830/us-east-1/bedrock/aws4_request"));
894 assert!(auth.contains("SignedHeaders=host;x-amz-content-sha256;x-amz-date"));
895 assert!(auth.contains("Signature="));
896
897 assert!(
899 signed
900 .headers
901 .iter()
902 .any(|(k, v)| *k == "X-Amz-Date" && v == "20150830T123600Z")
903 );
904 assert!(
905 !signed
906 .headers
907 .iter()
908 .any(|(k, _)| *k == "X-Amz-Security-Token")
909 );
910 }
911
912 #[test]
915 fn session_token_adds_security_token_header() {
916 let creds = AwsCredentials {
917 access_key_id: "AKIDEXAMPLE".to_string(),
918 secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(),
919 session_token: Some("FQoGZQ-token".to_string()),
920 };
921 let signed = bedrock_sig::build_signed_request(
922 "us-west-2",
923 "us.anthropic.claude-sonnet-4-6",
924 bedrock_body("sys", "usr"),
925 &creds,
926 "20150830T123600Z",
927 "20150830",
928 );
929
930 let token = signed
931 .headers
932 .iter()
933 .find(|(k, _)| *k == "X-Amz-Security-Token")
934 .map(|(_, v)| v.as_str());
935 assert_eq!(token, Some("FQoGZQ-token"));
936
937 let auth = signed
938 .headers
939 .iter()
940 .find(|(k, _)| *k == "Authorization")
941 .map(|(_, v)| v.as_str())
942 .unwrap();
943 assert!(auth.contains("x-amz-security-token"));
945 assert!(
947 signed
948 .url
949 .starts_with("https://bedrock-runtime.us-west-2.amazonaws.com/")
950 );
951 }
952
953 #[test]
956 fn bedrock_config_defaults() {
957 let cfg = ExtractionConfig::bedrock("eu-central-1");
958 assert_eq!(cfg.provider, LlmProvider::Bedrock);
959 assert!(cfg.api_key.is_none());
960 assert_eq!(cfg.region.as_deref(), Some("eu-central-1"));
961 assert_eq!(cfg.model, "us.anthropic.claude-haiku-4-5");
962 assert_eq!(LlmProvider::Bedrock.default_url(), "");
963 assert_eq!(
964 LlmProvider::Bedrock.default_reader_model(),
965 "us.anthropic.claude-sonnet-4-6"
966 );
967 }
968}