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