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