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