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