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