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::{Completion, LlmProvider, TokenUsage};
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    async fn complete_measured(
96        &self,
97        system_prompt: &str,
98        user_message: &str,
99        max_tokens: u32,
100    ) -> Result<Completion, GraphError> {
101        self.inner
102            .complete_measured(system_prompt, user_message, max_tokens)
103            .await
104    }
105}
106
107// ── HTTP providers (Anthropic + OpenAI-compat) ───────────────────────────
108
109/// API protocol style.
110#[derive(Debug, Clone)]
111pub enum ApiStyle {
112    Anthropic,
113    OpenAiCompat,
114}
115
116/// Resolved configuration for an HTTP LLM provider.
117#[derive(Debug, Clone)]
118pub struct HttpConfig {
119    pub api_key: String,
120    pub model: String,
121    pub api_base: String,
122    pub api_style: ApiStyle,
123    pub max_retries: u32,
124    pub retry_delay_ms: u64,
125}
126
127impl HttpConfig {
128    /// Build from a config LlmSection (for Anthropic/OpenAI providers only).
129    pub fn from_config_section(
130        llm: &config::LlmSection,
131    ) -> Result<Self, crate::error::RecallError> {
132        let api_style = match &llm.provider {
133            Provider::Anthropic => ApiStyle::Anthropic,
134            Provider::Openai => ApiStyle::OpenAiCompat,
135            other => {
136                return Err(crate::error::RecallError::Config(format!(
137                    "provider {other} spawns a CLI — use create_provider()"
138                )))
139            }
140        };
141
142        let api_key = env::var("RECALL_LLM_API_KEY")
143            .or_else(|_| match &api_style {
144                ApiStyle::Anthropic => env::var("ANTHROPIC_API_KEY"),
145                ApiStyle::OpenAiCompat => {
146                    env::var("OPENAI_API_KEY").or_else(|_| Ok("ollama".into()))
147                }
148            })
149            .map_err(|_| {
150                crate::error::RecallError::Config(
151                    "No API key found. Set ANTHROPIC_API_KEY or OPENAI_API_KEY in your environment."
152                        .into(),
153                )
154            })?;
155
156        let model = llm.resolved_model().to_string();
157        let api_base = llm.resolved_api_base().to_string();
158
159        let max_retries = env::var("RECALL_LLM_MAX_RETRIES")
160            .ok()
161            .and_then(|v| v.parse().ok())
162            .unwrap_or(3);
163
164        let retry_delay_ms = env::var("RECALL_LLM_RETRY_DELAY_MS")
165            .ok()
166            .and_then(|v| v.parse().ok())
167            .unwrap_or(1000);
168
169        Ok(Self {
170            api_key,
171            model,
172            api_base,
173            api_style,
174            max_retries,
175            retry_delay_ms,
176        })
177    }
178}
179
180/// HTTP-based LLM provider.
181pub struct HttpLlmProvider {
182    client: reqwest::Client,
183    config: HttpConfig,
184}
185
186impl HttpLlmProvider {
187    pub fn new(config: HttpConfig) -> Self {
188        Self {
189            client: reqwest::Client::new(),
190            config,
191        }
192    }
193
194    async fn try_complete(
195        &self,
196        system_prompt: &str,
197        user_message: &str,
198        max_tokens: u32,
199    ) -> Result<Completion, GraphError> {
200        match &self.config.api_style {
201            ApiStyle::Anthropic => {
202                self.complete_anthropic(system_prompt, user_message, max_tokens)
203                    .await
204            }
205            ApiStyle::OpenAiCompat => {
206                self.complete_openai(system_prompt, user_message, max_tokens)
207                    .await
208            }
209        }
210    }
211
212    async fn complete_anthropic(
213        &self,
214        system_prompt: &str,
215        user_message: &str,
216        max_tokens: u32,
217    ) -> Result<Completion, GraphError> {
218        let body = serde_json::json!({
219            "model": self.config.model,
220            "max_tokens": max_tokens,
221            "system": system_prompt,
222            "messages": [{"role": "user", "content": user_message}],
223        });
224
225        let response = self
226            .client
227            .post(&self.config.api_base)
228            .header("x-api-key", &self.config.api_key)
229            .header("anthropic-version", "2023-06-01")
230            .header("content-type", "application/json")
231            .json(&body)
232            .send()
233            .await
234            .map_err(|e| GraphError::Llm(format!("request failed: {e}")))?;
235
236        let status = response.status();
237        let text = response
238            .text()
239            .await
240            .map_err(|e| GraphError::Llm(format!("read body: {e}")))?;
241
242        if !status.is_success() {
243            return Err(GraphError::Llm(format!(
244                "API {}: {}",
245                status,
246                truncate_str(&text, 300)
247            )));
248        }
249
250        let json: serde_json::Value =
251            serde_json::from_str(&text).map_err(|e| GraphError::Llm(format!("parse: {e}")))?;
252
253        let text = json["content"][0]["text"]
254            .as_str()
255            .ok_or_else(|| GraphError::Llm("no text in anthropic response".into()))?;
256        Ok(Completion::measured(text, anthropic_usage(&json)))
257    }
258
259    async fn complete_openai(
260        &self,
261        system_prompt: &str,
262        user_message: &str,
263        max_tokens: u32,
264    ) -> Result<Completion, GraphError> {
265        let body = serde_json::json!({
266            "model": self.config.model,
267            "max_tokens": max_tokens,
268            "messages": [
269                {"role": "system", "content": system_prompt},
270                {"role": "user", "content": user_message},
271            ],
272        });
273
274        let url = format!(
275            "{}/chat/completions",
276            self.config.api_base.trim_end_matches('/')
277        );
278
279        let response = self
280            .client
281            .post(&url)
282            .header("Authorization", format!("Bearer {}", self.config.api_key))
283            .header("content-type", "application/json")
284            .json(&body)
285            .send()
286            .await
287            .map_err(|e| GraphError::Llm(format!("request failed: {e}")))?;
288
289        let status = response.status();
290        let text = response
291            .text()
292            .await
293            .map_err(|e| GraphError::Llm(format!("read body: {e}")))?;
294
295        if !status.is_success() {
296            return Err(GraphError::Llm(format!(
297                "API {}: {}",
298                status,
299                truncate_str(&text, 300)
300            )));
301        }
302
303        let json: serde_json::Value =
304            serde_json::from_str(&text).map_err(|e| GraphError::Llm(format!("parse: {e}")))?;
305
306        let text = json["choices"][0]["message"]["content"]
307            .as_str()
308            .ok_or_else(|| GraphError::Llm("no text in openai response".into()))?;
309        Ok(Completion::measured(text, openai_usage(&json)))
310    }
311
312    fn is_retryable(err: &GraphError) -> bool {
313        if let GraphError::Llm(msg) = err {
314            msg.contains("API 429") || msg.contains("API 5")
315        } else {
316            false
317        }
318    }
319}
320
321#[async_trait::async_trait]
322impl LlmProvider for HttpLlmProvider {
323    async fn complete(
324        &self,
325        system_prompt: &str,
326        user_message: &str,
327        max_tokens: u32,
328    ) -> Result<String, GraphError> {
329        Ok(self
330            .complete_measured(system_prompt, user_message, max_tokens)
331            .await?
332            .text)
333    }
334
335    /// Both API styles report their own token counts, so an HTTP call is
336    /// always measured — including the retried ones, whose counts are those of
337    /// the attempt that succeeded.
338    async fn complete_measured(
339        &self,
340        system_prompt: &str,
341        user_message: &str,
342        max_tokens: u32,
343    ) -> Result<Completion, GraphError> {
344        let mut last_error = None;
345
346        for attempt in 0..=self.config.max_retries {
347            if attempt > 0 {
348                tokio::time::sleep(std::time::Duration::from_millis(
349                    self.config.retry_delay_ms * u64::from(attempt),
350                ))
351                .await;
352            }
353
354            match self
355                .try_complete(system_prompt, user_message, max_tokens)
356                .await
357            {
358                Ok(completion) => return Ok(completion),
359                Err(e) => {
360                    if !Self::is_retryable(&e) || attempt == self.config.max_retries {
361                        return Err(e);
362                    }
363                    last_error = Some(e);
364                }
365            }
366        }
367
368        Err(last_error.unwrap_or_else(|| GraphError::Llm("no attempts made".into())))
369    }
370}
371
372// ── Helpers ──────────────────────────────────────────────────────────────
373
374/// Anthropic's counts: `usage.input_tokens` / `usage.output_tokens`.
375fn anthropic_usage(json: &serde_json::Value) -> Option<TokenUsage> {
376    TokenUsage::from_counts(
377        json["usage"]["input_tokens"].as_u64(),
378        json["usage"]["output_tokens"].as_u64(),
379    )
380}
381
382/// The OpenAI-compatible counts. Ollama and the other compatible servers use
383/// the same two keys; one that omits them is simply estimated.
384fn openai_usage(json: &serde_json::Value) -> Option<TokenUsage> {
385    TokenUsage::from_counts(
386        json["usage"]["prompt_tokens"].as_u64(),
387        json["usage"]["completion_tokens"].as_u64(),
388    )
389}
390
391fn truncate_str(text: &str, max: usize) -> &str {
392    let end = text.len().min(max);
393    let mut i = end;
394    while i > 0 && !text.is_char_boundary(i) {
395        i -= 1;
396    }
397    &text[..i]
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    /// Both HTTP styles report counts; recording them is what keeps an API
405    /// user's bill from being a length heuristic.
406    #[test]
407    fn the_anthropic_envelope_is_measured() {
408        let json = serde_json::json!({
409            "content": [{"type": "text", "text": "OK"}],
410            "usage": {"input_tokens": 1_200, "output_tokens": 48},
411        });
412        assert_eq!(
413            anthropic_usage(&json).map(TokenUsage::total),
414            Some(1_248),
415            "{json}"
416        );
417    }
418
419    #[test]
420    fn the_openai_envelope_is_measured() {
421        let json = serde_json::json!({
422            "choices": [{"message": {"content": "OK"}}],
423            "usage": {"prompt_tokens": 90, "completion_tokens": 10, "total_tokens": 100},
424        });
425        assert_eq!(openai_usage(&json).map(TokenUsage::total), Some(100));
426    }
427
428    /// A compatible server that omits the counts is estimated, not guessed at.
429    #[test]
430    fn an_envelope_without_counts_measures_nothing() {
431        let json = serde_json::json!({"choices": [{"message": {"content": "OK"}}]});
432        assert_eq!(openai_usage(&json), None);
433        assert_eq!(anthropic_usage(&json), None);
434    }
435}