1use 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
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
96#[derive(Debug, Clone)]
100pub enum ApiStyle {
101 Anthropic,
102 OpenAiCompat,
103}
104
105#[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 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
169pub 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
346fn 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}