Skip to main content

recall_echo/
llm_provider.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! LLM providers implementing crate::graph::LlmProvider.
6//!
7//! Two families:
8//! - **HTTP** — Anthropic (x-api-key) or any OpenAI-compatible endpoint,
9//!   Ollama included. Billed per token, or free when the endpoint is local.
10//! - **Agent CLI** — spawns the tool the user already subscribes to
11//!   (`claude`, `gemini`, `grok`, `codex`, or anything described in
12//!   `[llm.cli]`). No API key, no per-token billing. See
13//!   [`crate::cli_provider`].
14//!
15//! Provider/model/api_base loaded from `.recall-echo.toml` config.
16//! API keys read from environment variables (never stored in config).
17
18use std::env;
19use std::path::Path;
20
21use crate::graph::error::GraphError;
22use crate::graph::llm::LlmProvider;
23
24use crate::cli_provider::{CliProvider, CliSpec};
25use crate::config::{self, Provider};
26
27// ── Factory ──────────────────────────────────────────────────────────────
28
29/// Create the appropriate LlmProvider from config, with optional CLI overrides.
30///
31/// Returns the provider and the model name it settled on (empty when the CLI
32/// picks its own default).
33pub fn create_provider(
34    memory_dir: &Path,
35    provider_override: Option<&str>,
36    model_override: Option<&str>,
37) -> Result<(Box<dyn LlmProvider>, String), crate::error::RecallError> {
38    let mut cfg = config::load(memory_dir).llm;
39
40    if let Some(p) = provider_override {
41        cfg.provider = Provider::from_str_loose(p)?;
42    }
43    if let Some(m) = model_override {
44        cfg.model = m.to_string();
45    }
46
47    if cfg.provider.is_cli() {
48        let spec = CliSpec::resolve(&cfg.provider, &cfg.cli)?;
49        let model = spec.resolve_model(&cfg.model);
50        let provider = CliProvider::new(spec, model.clone());
51        return Ok((Box::new(provider), model));
52    }
53
54    let config = HttpConfig::from_config_section(&cfg)?;
55    let model = config.model.clone();
56    let provider = HttpLlmProvider::new(config);
57    Ok((Box::new(provider), model))
58}
59
60// ── Claude Code provider (subprocess) ────────────────────────────────────
61
62/// LLM provider that shells out to `claude -p` for completions.
63/// No API key needed — uses the user's Claude Code subscription.
64///
65/// A thin front for [`CliProvider`] on the `claude-code` preset, which builds
66/// the same argv this type always built.
67pub struct ClaudeCodeProvider {
68    inner: CliProvider,
69}
70
71impl ClaudeCodeProvider {
72    #[must_use]
73    pub fn new(model: String) -> Self {
74        let spec = CliSpec::preset(config::CliPreset::ClaudeCode);
75        let model = spec.resolve_model(&model);
76        Self {
77            inner: CliProvider::new(spec, model),
78        }
79    }
80}
81
82#[async_trait::async_trait]
83impl LlmProvider for ClaudeCodeProvider {
84    async fn complete(
85        &self,
86        system_prompt: &str,
87        user_message: &str,
88        max_tokens: u32,
89    ) -> Result<String, GraphError> {
90        self.inner
91            .complete(system_prompt, user_message, max_tokens)
92            .await
93    }
94}
95
96// ── HTTP providers (Anthropic + OpenAI-compat) ───────────────────────────
97
98/// API protocol style.
99#[derive(Debug, Clone)]
100pub enum ApiStyle {
101    Anthropic,
102    OpenAiCompat,
103}
104
105/// Resolved configuration for an HTTP LLM provider.
106#[derive(Debug, Clone)]
107pub struct HttpConfig {
108    pub api_key: String,
109    pub model: String,
110    pub api_base: String,
111    pub api_style: ApiStyle,
112    pub max_retries: u32,
113    pub retry_delay_ms: u64,
114}
115
116impl HttpConfig {
117    /// Build from a config LlmSection (for Anthropic/OpenAI providers only).
118    pub fn from_config_section(
119        llm: &config::LlmSection,
120    ) -> Result<Self, crate::error::RecallError> {
121        let api_style = match &llm.provider {
122            Provider::Anthropic => ApiStyle::Anthropic,
123            Provider::Openai => ApiStyle::OpenAiCompat,
124            other => {
125                return Err(crate::error::RecallError::Config(format!(
126                    "provider {other} spawns a CLI — use create_provider()"
127                )))
128            }
129        };
130
131        let api_key = env::var("RECALL_LLM_API_KEY")
132            .or_else(|_| match &api_style {
133                ApiStyle::Anthropic => env::var("ANTHROPIC_API_KEY"),
134                ApiStyle::OpenAiCompat => {
135                    env::var("OPENAI_API_KEY").or_else(|_| Ok("ollama".into()))
136                }
137            })
138            .map_err(|_| {
139                crate::error::RecallError::Config(
140                    "No API key found. Set ANTHROPIC_API_KEY or OPENAI_API_KEY in your environment."
141                        .into(),
142                )
143            })?;
144
145        let model = llm.resolved_model().to_string();
146        let api_base = llm.resolved_api_base().to_string();
147
148        let max_retries = env::var("RECALL_LLM_MAX_RETRIES")
149            .ok()
150            .and_then(|v| v.parse().ok())
151            .unwrap_or(3);
152
153        let retry_delay_ms = env::var("RECALL_LLM_RETRY_DELAY_MS")
154            .ok()
155            .and_then(|v| v.parse().ok())
156            .unwrap_or(1000);
157
158        Ok(Self {
159            api_key,
160            model,
161            api_base,
162            api_style,
163            max_retries,
164            retry_delay_ms,
165        })
166    }
167}
168
169/// HTTP-based LLM provider.
170pub struct HttpLlmProvider {
171    client: reqwest::Client,
172    config: HttpConfig,
173}
174
175impl HttpLlmProvider {
176    pub fn new(config: HttpConfig) -> Self {
177        Self {
178            client: reqwest::Client::new(),
179            config,
180        }
181    }
182
183    async fn try_complete(
184        &self,
185        system_prompt: &str,
186        user_message: &str,
187        max_tokens: u32,
188    ) -> Result<String, GraphError> {
189        match &self.config.api_style {
190            ApiStyle::Anthropic => {
191                self.complete_anthropic(system_prompt, user_message, max_tokens)
192                    .await
193            }
194            ApiStyle::OpenAiCompat => {
195                self.complete_openai(system_prompt, user_message, max_tokens)
196                    .await
197            }
198        }
199    }
200
201    async fn complete_anthropic(
202        &self,
203        system_prompt: &str,
204        user_message: &str,
205        max_tokens: u32,
206    ) -> Result<String, GraphError> {
207        let body = serde_json::json!({
208            "model": self.config.model,
209            "max_tokens": max_tokens,
210            "system": system_prompt,
211            "messages": [{"role": "user", "content": user_message}],
212        });
213
214        let response = self
215            .client
216            .post(&self.config.api_base)
217            .header("x-api-key", &self.config.api_key)
218            .header("anthropic-version", "2023-06-01")
219            .header("content-type", "application/json")
220            .json(&body)
221            .send()
222            .await
223            .map_err(|e| GraphError::Llm(format!("request failed: {e}")))?;
224
225        let status = response.status();
226        let text = response
227            .text()
228            .await
229            .map_err(|e| GraphError::Llm(format!("read body: {e}")))?;
230
231        if !status.is_success() {
232            return Err(GraphError::Llm(format!(
233                "API {}: {}",
234                status,
235                truncate_str(&text, 300)
236            )));
237        }
238
239        let json: serde_json::Value =
240            serde_json::from_str(&text).map_err(|e| GraphError::Llm(format!("parse: {e}")))?;
241
242        json["content"][0]["text"]
243            .as_str()
244            .map(String::from)
245            .ok_or_else(|| GraphError::Llm("no text in anthropic response".into()))
246    }
247
248    async fn complete_openai(
249        &self,
250        system_prompt: &str,
251        user_message: &str,
252        max_tokens: u32,
253    ) -> Result<String, GraphError> {
254        let body = serde_json::json!({
255            "model": self.config.model,
256            "max_tokens": max_tokens,
257            "messages": [
258                {"role": "system", "content": system_prompt},
259                {"role": "user", "content": user_message},
260            ],
261        });
262
263        let url = format!(
264            "{}/chat/completions",
265            self.config.api_base.trim_end_matches('/')
266        );
267
268        let response = self
269            .client
270            .post(&url)
271            .header("Authorization", format!("Bearer {}", self.config.api_key))
272            .header("content-type", "application/json")
273            .json(&body)
274            .send()
275            .await
276            .map_err(|e| GraphError::Llm(format!("request failed: {e}")))?;
277
278        let status = response.status();
279        let text = response
280            .text()
281            .await
282            .map_err(|e| GraphError::Llm(format!("read body: {e}")))?;
283
284        if !status.is_success() {
285            return Err(GraphError::Llm(format!(
286                "API {}: {}",
287                status,
288                truncate_str(&text, 300)
289            )));
290        }
291
292        let json: serde_json::Value =
293            serde_json::from_str(&text).map_err(|e| GraphError::Llm(format!("parse: {e}")))?;
294
295        json["choices"][0]["message"]["content"]
296            .as_str()
297            .map(String::from)
298            .ok_or_else(|| GraphError::Llm("no text in openai response".into()))
299    }
300
301    fn is_retryable(err: &GraphError) -> bool {
302        if let GraphError::Llm(msg) = err {
303            msg.contains("API 429") || msg.contains("API 5")
304        } else {
305            false
306        }
307    }
308}
309
310#[async_trait::async_trait]
311impl LlmProvider for HttpLlmProvider {
312    async fn complete(
313        &self,
314        system_prompt: &str,
315        user_message: &str,
316        max_tokens: u32,
317    ) -> Result<String, GraphError> {
318        let mut last_error = None;
319
320        for attempt in 0..=self.config.max_retries {
321            if attempt > 0 {
322                tokio::time::sleep(std::time::Duration::from_millis(
323                    self.config.retry_delay_ms * u64::from(attempt),
324                ))
325                .await;
326            }
327
328            match self
329                .try_complete(system_prompt, user_message, max_tokens)
330                .await
331            {
332                Ok(text) => return Ok(text),
333                Err(e) => {
334                    if !Self::is_retryable(&e) || attempt == self.config.max_retries {
335                        return Err(e);
336                    }
337                    last_error = Some(e);
338                }
339            }
340        }
341
342        Err(last_error.unwrap_or_else(|| GraphError::Llm("no attempts made".into())))
343    }
344}
345
346// ── Helpers ──────────────────────────────────────────────────────────────
347
348fn truncate_str(text: &str, max: usize) -> &str {
349    let end = text.len().min(max);
350    let mut i = end;
351    while i > 0 && !text.is_char_boundary(i) {
352        i -= 1;
353    }
354    &text[..i]
355}