Skip to main content

mentedb_extraction/
provider.rs

1use crate::config::{ExtractionConfig, LlmProvider};
2use crate::error::ExtractionError;
3
4/// AWS Bedrock SigV4 signing for the Anthropic Messages API on Bedrock.
5///
6/// This ports the hand-rolled SigV4 signer from `mentedb-embedding`'s
7/// `bedrock_provider` (which signs a synchronous `ureq` call) to produce the
8/// headers for an asynchronous `reqwest` request. Credentials are reused from
9/// `mentedb_embedding::AwsCredentials`. Only compiled with the `bedrock`
10/// feature.
11#[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    /// SigV4 signing key: HMAC chain over date, region, service, "aws4_request".
40    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    /// URI-encode a single path segment per SigV4 rules (unreserved chars pass
48    /// through; everything else, including the `:` in a model id, is percent
49    /// encoded). The request URL and the signed canonical URI must match.
50    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    /// A fully signed Bedrock request, ready to hand to `reqwest`.
63    pub(super) struct SignedRequest {
64        pub url: String,
65        pub body: Vec<u8>,
66        /// Header (name, value) pairs to set on the request. Includes
67        /// `Authorization`, `X-Amz-Date`, `X-Amz-Content-Sha256`, and
68        /// `X-Amz-Security-Token` when a session token is present.
69        pub headers: Vec<(&'static str, String)>,
70    }
71
72    /// Build and SigV4-sign a Bedrock InvokeModel request for the given region,
73    /// model, and JSON body. `amzdate`/`datestamp` are passed in (rather than
74    /// read from the clock) so this is deterministically testable; the live
75    /// caller passes the current UTC time.
76    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        // Sign the percent-encoded path; send the raw path. AWS re-encodes the
86        // received path the same way, so the signatures match (this is what the
87        // AWS SDKs do for model ids containing ':').
88        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
137/// Classify an HTTP error response into a specific ExtractionError variant.
138fn 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
160/// Trait for LLM providers that can extract memories from conversation text.
161pub trait ExtractionProvider: Send + Sync {
162    /// Send a conversation to the LLM with the given system prompt and return
163    /// the raw response text (expected to be JSON).
164    fn extract(
165        &self,
166        conversation: &str,
167        system_prompt: &str,
168    ) -> impl std::future::Future<Output = Result<String, ExtractionError>> + Send;
169}
170
171/// HTTP-based extraction provider that calls OpenAI, Anthropic, or Ollama APIs.
172pub struct HttpExtractionProvider {
173    client: reqwest::Client,
174    config: ExtractionConfig,
175}
176
177impl HttpExtractionProvider {
178    pub fn new(config: ExtractionConfig) -> Result<Self, ExtractionError> {
179        // Ollama needs no auth; Bedrock authenticates with AWS credentials from
180        // the environment (verified below), not an api_key. Every other
181        // provider requires an api_key.
182        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                // Fail fast with a clear message if AWS creds are missing,
192                // rather than at the first extraction call.
193                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    /// Expand a search query into multiple sub-queries via LLM.
217    ///
218    /// Given a natural language question, identifies the expected answer type
219    /// and extracts 2-3 targeted search queries. The first line of the response
220    /// is the answer type (PLACE, DATE, NUMBER, NAME, PERSON, BRAND, etc.),
221    /// followed by the search queries.
222    ///
223    /// For counting/aggregation/comparison queries, also generates comprehensive
224    /// category synonyms for exhaustive BM25 sweep.
225    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        // Parse JSON response (call_openai forces json_object response format)
251        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            // Fallback: old single "keywords" field → treat all as item keywords
275            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            // Fallback: parse as plain text lines
282            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    /// OpenAI call without forced JSON response format.
342    /// Used for plain text outputs (synthesis, re-ranking, key noun extraction).
343    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        // Anthropic may return multiple content blocks; find the first text block
431        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                // Fallback: try the old path for backwards compat
444                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    /// Call AWS Bedrock's Anthropic Messages API, signed with SigV4.
468    ///
469    /// The endpoint is built from `config.region` and `config.model`; the body
470    /// uses the Bedrock Anthropic Messages format. Credentials are read from the
471    /// AWS environment via `mentedb_embedding::AwsCredentials`. Compiled only
472    /// with the `bedrock` feature; otherwise returns a clear ConfigError.
473    #[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            &region,
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        // Bedrock returns the Anthropic content-block shape: concatenate all
542        // text blocks (mirrors call_anthropic's fallback behavior on empty).
543        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    /// Feature-disabled stub: when built without `--features bedrock`, selecting
567    /// the Bedrock provider fails with a clear, actionable message instead of a
568    /// panic.
569    #[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    /// Execute a request with retry logic for rate limits (HTTP 429).
625    /// Uses exponential backoff: 1s, 2s, 4s.
626    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    /// Like call_with_retry but without forcing JSON response format.
636    /// Use for prompts that expect plain text output (synthesis, re-ranking, etc).
637    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                // Bedrock (Anthropic on Bedrock) handles both the JSON and text
683                // paths with one method, like the native Anthropic provider.
684                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
733/// Mock extraction provider for testing. Returns a predefined JSON response.
734pub struct MockExtractionProvider {
735    response: String,
736}
737
738impl MockExtractionProvider {
739    /// Create a mock provider that always returns the given JSON string.
740    pub fn new(response: impl Into<String>) -> Self {
741        Self {
742            response: response.into(),
743        }
744    }
745
746    /// Create a mock provider with a realistic extraction response.
747    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    /// Build the exact JSON body call_bedrock sends, so the test and the real
828    /// code stay in sync on the wire format.
829    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    /// Construct a signed Bedrock request with fixed credentials and timestamp
842    /// (no network call) and assert: the URL is the region/model
843    /// bedrock-runtime path, the body is valid Anthropic-Bedrock JSON carrying
844    /// the system + user message, and an `Authorization: AWS4-HMAC-SHA256`
845    /// header is produced.
846    #[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        // URL is the region/model bedrock-runtime InvokeModel path (raw model id).
869        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        // Body is valid Anthropic-Bedrock JSON with system + user message.
875        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        // An AWS4-HMAC-SHA256 Authorization header is produced.
883        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        // X-Amz-Date is set; no security token header without a session token.
898        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    /// Signing is deterministic for fixed inputs, and a session token adds the
913    /// X-Amz-Security-Token header (and includes it in SignedHeaders).
914    #[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        // The session token participates in the signed headers.
944        assert!(auth.contains("x-amz-security-token"));
945        // The region flows into the endpoint host.
946        assert!(
947            signed
948                .url
949                .starts_with("https://bedrock-runtime.us-west-2.amazonaws.com/")
950        );
951    }
952
953    /// The config defaults for the Bedrock provider are the Claude-on-Bedrock
954    /// model ids and an empty (region-derived) default URL.
955    #[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}