1use 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
27pub 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
60pub 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#[derive(Debug, Clone)]
111pub enum ApiStyle {
112 Anthropic,
113 OpenAiCompat,
114}
115
116#[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 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
180pub 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 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
372fn 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
382fn 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 #[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 #[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}