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
171fn claude_body(
178 config: &ExtractionConfig,
179 system_prompt: &str,
180 conversation: &str,
181) -> serde_json::Value {
182 let mut body = serde_json::json!({
183 "max_tokens": config.max_output_tokens,
184 "system": system_prompt,
185 "messages": [
186 { "role": "user", "content": conversation }
187 ]
188 });
189 if let Some(effort) = &config.effort {
190 body["output_config"] = serde_json::json!({ "effort": effort });
191 }
192 body
193}
194
195#[cfg(feature = "bedrock")]
197fn bedrock_body(
198 config: &ExtractionConfig,
199 system_prompt: &str,
200 conversation: &str,
201) -> serde_json::Value {
202 let mut body = claude_body(config, system_prompt, conversation);
203 body["anthropic_version"] = serde_json::json!("bedrock-2023-05-31");
204 body
205}
206
207fn claude_text(
213 parsed: &serde_json::Value,
214 config: &ExtractionConfig,
215 provider: &str,
216 raw: &str,
217) -> Result<String, ExtractionError> {
218 if parsed["stop_reason"].as_str() == Some("max_tokens") {
219 return Err(ExtractionError::Truncated {
220 model: config.model.clone(),
221 max_output_tokens: config.max_output_tokens,
222 });
223 }
224 let text: String = parsed["content"]
225 .as_array()
226 .map(|blocks| {
227 blocks
228 .iter()
229 .filter(|block| block["type"].as_str() == Some("text"))
230 .filter_map(|block| block["text"].as_str())
231 .collect::<Vec<_>>()
232 .join("")
233 })
234 .unwrap_or_default();
235 if text.trim().is_empty() {
236 tracing::warn!(
237 model = %config.model,
238 response_preview = mentedb_core::text::truncate_on_char_boundary(raw, 300),
239 "No text block found in {provider} response"
240 );
241 return Ok("{\"memories\": []}".to_string());
242 }
243 Ok(text)
244}
245
246pub struct HttpExtractionProvider {
247 client: reqwest::Client,
248 config: ExtractionConfig,
249}
250
251impl HttpExtractionProvider {
252 fn truncated(&self) -> ExtractionError {
253 ExtractionError::Truncated {
254 model: self.config.model.clone(),
255 max_output_tokens: self.config.max_output_tokens,
256 }
257 }
258
259 pub fn new(config: ExtractionConfig) -> Result<Self, ExtractionError> {
260 let needs_api_key = !matches!(config.provider, LlmProvider::Ollama | LlmProvider::Bedrock);
264 if needs_api_key && config.api_key.is_none() {
265 return Err(ExtractionError::ConfigError(
266 "API key is required for this provider".to_string(),
267 ));
268 }
269 if config.provider == LlmProvider::Bedrock {
270 #[cfg(feature = "bedrock")]
271 {
272 mentedb_embedding::AwsCredentials::from_env().map_err(|e| {
275 ExtractionError::ConfigError(format!(
276 "Bedrock requires AWS credentials in the environment \
277 (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, and \
278 AWS_SESSION_TOKEN for temporary/SSO credentials): {e}"
279 ))
280 })?;
281 }
282 #[cfg(not(feature = "bedrock"))]
283 {
284 return Err(ExtractionError::ConfigError(
285 "bedrock support not compiled in (build with --features bedrock)".to_string(),
286 ));
287 }
288 }
289 let client = reqwest::Client::builder()
290 .timeout(std::time::Duration::from_secs(120))
291 .connect_timeout(std::time::Duration::from_secs(30))
292 .build()
293 .map_err(|e| ExtractionError::ConfigError(format!("HTTP client error: {}", e)))?;
294 Ok(Self { client, config })
295 }
296
297 pub async fn expand_query(&self, query: &str) -> Result<Vec<String>, ExtractionError> {
307 let system_prompt = "You help search a memory database. Given a question, return a JSON object with:\n\
308 - \"answer_type\": one of PLACE, DATE, TIME, NUMBER, NAME, PERSON, BRAND, ITEM, ACTIVITY, COUNTING, OTHER\n\
309 - \"queries\": array of 2-3 short search queries\n\
310 - For COUNTING only, also include:\n\
311 - \"item_keywords\": comma-separated specific subtypes/instances that would be individually counted\n\
312 - \"broad_keywords\": comma-separated category terms, action verbs, and general synonyms\n\n\
313 Use COUNTING when the question requires COMPLETENESS — counting, listing, aggregating, totaling, \
314 or comparing to find a superlative (most, least, best, worst, first, last, biggest, highest, lowest).\n\n\
315 The distinction matters:\n\
316 - item_keywords: specific things you would COUNT (types of the thing being asked about)\n\
317 - broad_keywords: general terms that help FIND memories but aren't counted themselves\n\n\
318 Examples:\n\
319 Q: \"Where do I take yoga classes?\"\n\
320 {\"answer_type\": \"PLACE\", \"queries\": [\"yoga studio name\", \"yoga class location\"]}\n\n\
321 Q: \"How many doctors did I visit?\"\n\
322 {\"answer_type\": \"COUNTING\", \"queries\": [\"doctor visits appointments\", \"medical specialist visits\"], \
323 \"item_keywords\": \"doctor, Dr., physician, specialist, dermatologist, cardiologist, dentist, surgeon, pediatrician, orthopedist, ophthalmologist\", \
324 \"broad_keywords\": \"medical, clinic, appointment, visit, diagnosed, prescribed, referred, checkup, exam\"}\n\n\
325 Q: \"Which platform did I gain the most followers on?\"\n\
326 {\"answer_type\": \"COUNTING\", \"queries\": [\"social media follower growth\", \"follower count increase\"], \
327 \"item_keywords\": \"TikTok, Instagram, Twitter, YouTube, Facebook, LinkedIn, Snapchat, Reddit, Twitch\", \
328 \"broad_keywords\": \"followers, follower count, gained, growth, platform, social media, increase, jumped, grew\"}";
329 let result = self.call_with_retry(query, system_prompt).await?;
330
331 let mut lines: Vec<String> = Vec::new();
333 let cleaned = result
334 .trim()
335 .trim_start_matches("```json")
336 .trim_end_matches("```")
337 .trim();
338 if let Ok(json) = serde_json::from_str::<serde_json::Value>(cleaned) {
339 if let Some(answer_type) = json.get("answer_type").and_then(|v| v.as_str()) {
340 lines.push(answer_type.to_string());
341 }
342 if let Some(queries) = json.get("queries").and_then(|v| v.as_array()) {
343 for q in queries {
344 if let Some(s) = q.as_str() {
345 lines.push(s.to_string());
346 }
347 }
348 }
349 if let Some(item_kw) = json.get("item_keywords").and_then(|v| v.as_str()) {
350 lines.push(format!("ITEM_KEYWORDS: {}", item_kw));
351 }
352 if let Some(broad_kw) = json.get("broad_keywords").and_then(|v| v.as_str()) {
353 lines.push(format!("BROAD_KEYWORDS: {}", broad_kw));
354 }
355 if let Some(keywords) = json.get("keywords").and_then(|v| v.as_str())
357 && json.get("item_keywords").is_none()
358 {
359 lines.push(format!("ITEM_KEYWORDS: {}", keywords));
360 }
361 } else {
362 lines = result
364 .lines()
365 .map(|l| l.trim().to_string())
366 .filter(|l| !l.is_empty())
367 .collect();
368 }
369 if std::env::var("MENTEDB_DEBUG").is_ok() {
370 eprintln!("[expand_query] input={:?} parsed={:?}", query, lines);
371 }
372 Ok(lines)
373 }
374
375 async fn call_openai(
376 &self,
377 conversation: &str,
378 system_prompt: &str,
379 ) -> Result<String, ExtractionError> {
380 let body = serde_json::json!({
381 "model": self.config.model,
382 "temperature": 0,
383 "response_format": { "type": "json_object" },
384 "messages": [
385 { "role": "system", "content": system_prompt },
386 { "role": "user", "content": conversation }
387 ]
388 });
389
390 let api_key = self.config.api_key.as_deref().unwrap_or_default();
391
392 let resp = self
393 .client
394 .post(&self.config.api_url)
395 .header("Authorization", format!("Bearer {api_key}"))
396 .header("Content-Type", "application/json")
397 .json(&body)
398 .send()
399 .await?;
400
401 let status = resp.status();
402 let text = resp.text().await?;
403
404 if !status.is_success() {
405 return Err(classify_api_error(
406 status,
407 &text,
408 "OpenAI",
409 &self.config.model,
410 ));
411 }
412
413 let parsed: serde_json::Value = serde_json::from_str(&text)?;
414 if parsed["choices"][0]["finish_reason"].as_str() == Some("length") {
415 return Err(self.truncated());
416 }
417 parsed["choices"][0]["message"]["content"]
418 .as_str()
419 .map(|s| s.to_string())
420 .ok_or_else(|| {
421 ExtractionError::ParseError("Missing content in OpenAI response".to_string())
422 })
423 }
424
425 async fn call_openai_text(
428 &self,
429 conversation: &str,
430 system_prompt: &str,
431 ) -> Result<String, ExtractionError> {
432 let body = serde_json::json!({
433 "model": self.config.model,
434 "temperature": 0,
435 "messages": [
436 { "role": "system", "content": system_prompt },
437 { "role": "user", "content": conversation }
438 ]
439 });
440
441 let api_key = self.config.api_key.as_deref().unwrap_or_default();
442
443 let resp = self
444 .client
445 .post(&self.config.api_url)
446 .header("Authorization", format!("Bearer {api_key}"))
447 .header("Content-Type", "application/json")
448 .json(&body)
449 .send()
450 .await?;
451
452 let status = resp.status();
453 let text = resp.text().await?;
454
455 if !status.is_success() {
456 return Err(classify_api_error(
457 status,
458 &text,
459 "OpenAI",
460 &self.config.model,
461 ));
462 }
463
464 let parsed: serde_json::Value = serde_json::from_str(&text)?;
465 if parsed["choices"][0]["finish_reason"].as_str() == Some("length") {
466 return Err(self.truncated());
467 }
468 parsed["choices"][0]["message"]["content"]
469 .as_str()
470 .map(|s| s.to_string())
471 .ok_or_else(|| {
472 ExtractionError::ParseError("Missing content in OpenAI response".to_string())
473 })
474 }
475
476 async fn call_anthropic(
477 &self,
478 conversation: &str,
479 system_prompt: &str,
480 ) -> Result<String, ExtractionError> {
481 let mut body = claude_body(&self.config, system_prompt, conversation);
482 body["model"] = serde_json::json!(self.config.model);
483
484 let api_key = self.config.api_key.as_deref().unwrap_or_default();
485
486 let resp = self
487 .client
488 .post(&self.config.api_url)
489 .header("x-api-key", api_key)
490 .header("anthropic-version", "2023-06-01")
491 .header("Content-Type", "application/json")
492 .json(&body)
493 .send()
494 .await?;
495
496 let status = resp.status();
497 let text = resp.text().await?;
498
499 if !status.is_success() {
500 return Err(classify_api_error(
501 status,
502 &text,
503 "Anthropic",
504 &self.config.model,
505 ));
506 }
507
508 let parsed: serde_json::Value = serde_json::from_str(&text)?;
509 claude_text(&parsed, &self.config, "Anthropic", &text)
510 }
511
512 #[cfg(feature = "bedrock")]
519 async fn call_bedrock(
520 &self,
521 conversation: &str,
522 system_prompt: &str,
523 ) -> Result<String, ExtractionError> {
524 let region = self
525 .config
526 .region
527 .clone()
528 .unwrap_or_else(crate::config::default_bedrock_region);
529
530 let body = serde_json::to_vec(&bedrock_body(&self.config, system_prompt, conversation))?;
531
532 let creds = mentedb_embedding::AwsCredentials::from_env().map_err(|e| {
533 ExtractionError::ConfigError(format!(
534 "Bedrock requires AWS credentials in the environment \
535 (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN \
536 for temporary/SSO credentials): {e}"
537 ))
538 })?;
539
540 let now = chrono::Utc::now();
541 let amzdate = now.format("%Y%m%dT%H%M%SZ").to_string();
542 let datestamp = now.format("%Y%m%d").to_string();
543
544 let signed = bedrock_sig::build_signed_request(
545 ®ion,
546 &self.config.model,
547 body,
548 &creds,
549 &amzdate,
550 &datestamp,
551 );
552
553 let mut req = self
554 .client
555 .post(&signed.url)
556 .header("Content-Type", "application/json")
557 .header("Accept", "application/json");
558 for (name, value) in &signed.headers {
559 req = req.header(*name, value);
560 }
561
562 let resp = req.body(signed.body).send().await?;
563
564 let status = resp.status();
565 let text = resp.text().await?;
566
567 if !status.is_success() {
568 return Err(classify_api_error(
569 status,
570 &text,
571 "Bedrock",
572 &self.config.model,
573 ));
574 }
575
576 let parsed: serde_json::Value = serde_json::from_str(&text)?;
577 claude_text(&parsed, &self.config, "Bedrock", &text)
578 }
579
580 #[cfg(not(feature = "bedrock"))]
584 async fn call_bedrock(
585 &self,
586 _conversation: &str,
587 _system_prompt: &str,
588 ) -> Result<String, ExtractionError> {
589 Err(ExtractionError::ConfigError(
590 "bedrock support not compiled in (build with --features bedrock)".to_string(),
591 ))
592 }
593
594 async fn call_ollama(
595 &self,
596 conversation: &str,
597 system_prompt: &str,
598 ) -> Result<String, ExtractionError> {
599 let body = serde_json::json!({
600 "model": self.config.model,
601 "stream": false,
602 "format": "json",
603 "messages": [
604 { "role": "system", "content": system_prompt },
605 { "role": "user", "content": conversation }
606 ]
607 });
608
609 let resp = self
610 .client
611 .post(&self.config.api_url)
612 .header("Content-Type", "application/json")
613 .json(&body)
614 .send()
615 .await?;
616
617 let status = resp.status();
618 let text = resp.text().await?;
619
620 if !status.is_success() {
621 return Err(classify_api_error(
622 status,
623 &text,
624 "Ollama",
625 &self.config.model,
626 ));
627 }
628
629 let parsed: serde_json::Value = serde_json::from_str(&text)?;
630 parsed["message"]["content"]
631 .as_str()
632 .map(|s| s.to_string())
633 .ok_or_else(|| {
634 ExtractionError::ParseError("Missing content in Ollama response".to_string())
635 })
636 }
637
638 pub async fn call_with_retry(
641 &self,
642 conversation: &str,
643 system_prompt: &str,
644 ) -> Result<String, ExtractionError> {
645 self.call_with_retry_inner(conversation, system_prompt, true)
646 .await
647 }
648
649 pub async fn call_text_with_retry(
652 &self,
653 conversation: &str,
654 system_prompt: &str,
655 ) -> Result<String, ExtractionError> {
656 self.call_with_retry_inner(conversation, system_prompt, false)
657 .await
658 }
659
660 async fn call_with_retry_inner(
661 &self,
662 conversation: &str,
663 system_prompt: &str,
664 force_json: bool,
665 ) -> Result<String, ExtractionError> {
666 let max_attempts = 3;
667 let mut last_err = None;
668
669 for attempt in 0..max_attempts {
670 if attempt > 0 {
671 let delay = std::time::Duration::from_secs(1 << attempt);
672 tracing::warn!(
673 attempt,
674 delay_secs = delay.as_secs(),
675 "retrying after rate limit"
676 );
677 tokio::time::sleep(delay).await;
678 }
679
680 tracing::info!(
681 provider = ?self.config.provider,
682 model = %self.config.model,
683 attempt = attempt + 1,
684 "calling LLM extraction API"
685 );
686
687 let result = match self.config.provider {
688 LlmProvider::OpenAI | LlmProvider::Custom => {
689 if force_json {
690 self.call_openai(conversation, system_prompt).await
691 } else {
692 self.call_openai_text(conversation, system_prompt).await
693 }
694 }
695 LlmProvider::Anthropic => self.call_anthropic(conversation, system_prompt).await,
696 LlmProvider::Bedrock => self.call_bedrock(conversation, system_prompt).await,
699 LlmProvider::Ollama => self.call_ollama(conversation, system_prompt).await,
700 };
701
702 match result {
703 Ok(text) => {
704 tracing::info!(response_len = text.len(), "LLM extraction complete");
705 return Ok(text);
706 }
707 Err(ExtractionError::ProviderError(ref msg))
708 if msg.contains("429")
709 || msg.contains("500")
710 || msg.contains("502")
711 || msg.contains("503")
712 || msg.contains("529")
713 || msg.contains("timeout")
714 || msg.contains("connection")
715 || msg.contains("overloaded") =>
716 {
717 tracing::warn!(attempt = attempt + 1, error = %msg, "retrying transient LLM error");
718 last_err = Some(result.unwrap_err());
719 continue;
720 }
721 Err(e) => {
722 tracing::error!(error = %e, "LLM extraction failed (non-retryable)");
723 return Err(e);
724 }
725 }
726 }
727
728 match last_err {
729 Some(e) => Err(e),
730 None => Err(ExtractionError::RateLimitExceeded {
731 attempts: max_attempts,
732 }),
733 }
734 }
735}
736
737impl ExtractionProvider for HttpExtractionProvider {
738 async fn extract(
739 &self,
740 conversation: &str,
741 system_prompt: &str,
742 ) -> Result<String, ExtractionError> {
743 self.call_with_retry(conversation, system_prompt).await
744 }
745}
746
747pub struct MockExtractionProvider {
749 response: String,
750}
751
752impl MockExtractionProvider {
753 pub fn new(response: impl Into<String>) -> Self {
755 Self {
756 response: response.into(),
757 }
758 }
759
760 pub fn with_realistic_response() -> Self {
762 let response = serde_json::json!({
763 "memories": [
764 {
765 "content": "The team decided to use PostgreSQL 15 as the primary database for the REST API project",
766 "memory_type": "decision",
767 "confidence": 0.95,
768 "entities": ["PostgreSQL", "REST API"],
769 "tags": ["database", "architecture"],
770 "reasoning": "Explicitly decided after comparing options"
771 },
772 {
773 "content": "REST endpoints should follow the /api/v1/ prefix convention",
774 "memory_type": "decision",
775 "confidence": 0.9,
776 "entities": ["REST API"],
777 "tags": ["api-design", "conventions"],
778 "reasoning": "Team agreed on URL structure"
779 },
780 {
781 "content": "User prefers Rust over Go for backend services due to memory safety guarantees",
782 "memory_type": "preference",
783 "confidence": 0.85,
784 "entities": ["Rust", "Go"],
785 "tags": ["language", "backend"],
786 "reasoning": "Explicitly stated preference with clear reasoning"
787 },
788 {
789 "content": "The initial plan to use MongoDB was incorrect; PostgreSQL is the right choice for relational data",
790 "memory_type": "correction",
791 "confidence": 0.9,
792 "entities": ["MongoDB", "PostgreSQL"],
793 "tags": ["database", "correction"],
794 "reasoning": "Corrected an earlier wrong assumption"
795 },
796 {
797 "content": "The project deadline is March 15, 2025",
798 "memory_type": "fact",
799 "confidence": 0.8,
800 "entities": ["REST API project"],
801 "tags": ["timeline"],
802 "reasoning": "Confirmed date mentioned in discussion"
803 },
804 {
805 "content": "Using global mutable state for database connections caused race conditions in testing",
806 "memory_type": "anti_pattern",
807 "confidence": 0.85,
808 "entities": [],
809 "tags": ["testing", "concurrency"],
810 "reasoning": "Documented failure pattern to avoid repeating"
811 },
812 {
813 "content": "Low confidence speculation about maybe using Redis",
814 "memory_type": "fact",
815 "confidence": 0.3,
816 "entities": ["Redis"],
817 "tags": ["cache"],
818 "reasoning": "Mentioned but not confirmed"
819 }
820 ]
821 });
822 Self::new(response.to_string())
823 }
824}
825
826impl ExtractionProvider for MockExtractionProvider {
827 async fn extract(
828 &self,
829 _conversation: &str,
830 _system_prompt: &str,
831 ) -> Result<String, ExtractionError> {
832 Ok(self.response.clone())
833 }
834}
835
836#[cfg(test)]
837mod claude_tests {
838 use super::*;
839
840 fn cfg() -> ExtractionConfig {
841 ExtractionConfig::anthropic("k")
842 }
843
844 #[test]
845 fn body_leaves_room_for_thinking_and_omits_temperature() {
846 let body = claude_body(&cfg(), "sys", "usr");
847 assert_eq!(body["max_tokens"], 16_000);
848 assert!(body.get("temperature").is_none());
849 assert!(body.get("output_config").is_none());
850 }
851
852 #[test]
853 fn effort_is_sent_only_when_set() {
854 let mut c = cfg();
855 c.effort = Some("low".to_string());
856 c.max_output_tokens = 2_000;
857 let body = claude_body(&c, "sys", "usr");
858 assert_eq!(body["output_config"]["effort"], "low");
859 assert_eq!(body["max_tokens"], 2_000);
860 }
861
862 #[test]
863 fn thinking_blocks_are_skipped() {
864 let parsed = serde_json::json!({
865 "stop_reason": "end_turn",
866 "content": [
867 { "type": "thinking", "thinking": "let me see" },
868 { "type": "text", "text": "{\"memories\": [1]}" }
869 ]
870 });
871 let text = claude_text(&parsed, &cfg(), "Anthropic", "").unwrap();
872 assert_eq!(text, "{\"memories\": [1]}");
873 }
874
875 #[test]
876 fn output_limit_hit_during_thinking_is_an_error() {
877 let parsed = serde_json::json!({
880 "stop_reason": "max_tokens",
881 "content": [{ "type": "thinking", "thinking": "partial" }]
882 });
883 let err = claude_text(&parsed, &cfg(), "Bedrock", "").unwrap_err();
884 assert!(matches!(
885 err,
886 ExtractionError::Truncated {
887 max_output_tokens: 16_000,
888 ..
889 }
890 ));
891 }
892
893 #[test]
894 fn output_limit_hit_mid_answer_is_an_error() {
895 let parsed = serde_json::json!({
896 "stop_reason": "max_tokens",
897 "content": [{ "type": "text", "text": "{\"memories\": [" }]
898 });
899 assert!(claude_text(&parsed, &cfg(), "Anthropic", "").is_err());
900 }
901}
902
903#[cfg(all(test, feature = "bedrock"))]
904mod bedrock_tests {
905 use super::*;
906 use mentedb_embedding::AwsCredentials;
907
908 fn bedrock_body(system: &str, user: &str) -> Vec<u8> {
911 let cfg = ExtractionConfig::bedrock("us-east-1");
912 serde_json::to_vec(&super::bedrock_body(&cfg, system, user)).unwrap()
913 }
914
915 #[test]
921 fn signed_bedrock_request_has_expected_url_body_and_auth() {
922 let creds = AwsCredentials {
923 access_key_id: "AKIDEXAMPLE".to_string(),
924 secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(),
925 session_token: None,
926 };
927 let region = "us-east-1";
928 let model = "us.anthropic.claude-haiku-5-5";
929 let system = "You extract memories.";
930 let user = "I switched my database to PostgreSQL.";
931 let body = bedrock_body(system, user);
932
933 let signed = bedrock_sig::build_signed_request(
934 region,
935 model,
936 body,
937 &creds,
938 "20150830T123600Z",
939 "20150830",
940 );
941
942 assert_eq!(
944 signed.url,
945 "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-5-5/invoke"
946 );
947
948 let parsed: serde_json::Value = serde_json::from_slice(&signed.body).unwrap();
950 assert_eq!(parsed["anthropic_version"], "bedrock-2023-05-31");
951 assert_eq!(parsed["max_tokens"], 16_000);
952 assert!(parsed.get("temperature").is_none());
953 assert!(parsed.get("output_config").is_none());
954 assert_eq!(parsed["system"], system);
955 assert_eq!(parsed["messages"][0]["role"], "user");
956 assert_eq!(parsed["messages"][0]["content"], user);
957
958 let auth = signed
960 .headers
961 .iter()
962 .find(|(k, _)| *k == "Authorization")
963 .map(|(_, v)| v.as_str())
964 .expect("Authorization header present");
965 assert!(
966 auth.starts_with("AWS4-HMAC-SHA256 "),
967 "unexpected auth scheme: {auth}"
968 );
969 assert!(auth.contains("Credential=AKIDEXAMPLE/20150830/us-east-1/bedrock/aws4_request"));
970 assert!(auth.contains("SignedHeaders=host;x-amz-content-sha256;x-amz-date"));
971 assert!(auth.contains("Signature="));
972
973 assert!(
975 signed
976 .headers
977 .iter()
978 .any(|(k, v)| *k == "X-Amz-Date" && v == "20150830T123600Z")
979 );
980 assert!(
981 !signed
982 .headers
983 .iter()
984 .any(|(k, _)| *k == "X-Amz-Security-Token")
985 );
986 }
987
988 #[test]
991 fn session_token_adds_security_token_header() {
992 let creds = AwsCredentials {
993 access_key_id: "AKIDEXAMPLE".to_string(),
994 secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(),
995 session_token: Some("FQoGZQ-token".to_string()),
996 };
997 let signed = bedrock_sig::build_signed_request(
998 "us-west-2",
999 "us.anthropic.claude-sonnet-5-5",
1000 bedrock_body("sys", "usr"),
1001 &creds,
1002 "20150830T123600Z",
1003 "20150830",
1004 );
1005
1006 let token = signed
1007 .headers
1008 .iter()
1009 .find(|(k, _)| *k == "X-Amz-Security-Token")
1010 .map(|(_, v)| v.as_str());
1011 assert_eq!(token, Some("FQoGZQ-token"));
1012
1013 let auth = signed
1014 .headers
1015 .iter()
1016 .find(|(k, _)| *k == "Authorization")
1017 .map(|(_, v)| v.as_str())
1018 .unwrap();
1019 assert!(auth.contains("x-amz-security-token"));
1021 assert!(
1023 signed
1024 .url
1025 .starts_with("https://bedrock-runtime.us-west-2.amazonaws.com/")
1026 );
1027 }
1028
1029 #[test]
1032 fn bedrock_config_defaults() {
1033 let cfg = ExtractionConfig::bedrock("eu-central-1");
1034 assert_eq!(cfg.provider, LlmProvider::Bedrock);
1035 assert!(cfg.api_key.is_none());
1036 assert_eq!(cfg.region.as_deref(), Some("eu-central-1"));
1037 assert_eq!(cfg.model, "us.anthropic.claude-haiku-5-5");
1038 assert_eq!(LlmProvider::Bedrock.default_url(), "");
1039 assert_eq!(
1040 LlmProvider::Bedrock.default_reader_model(),
1041 "us.anthropic.claude-sonnet-5-5"
1042 );
1043 }
1044}