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