1use std::env;
9
10use anyhow::{Context, Result};
11use async_trait::async_trait;
12use reqwest::Client;
13use secrecy::{ExposeSecret, SecretString};
14use serde::{Deserialize, Serialize};
15
16use super::circuit_breaker::CircuitBreaker;
17use super::provider::AiProvider;
18use super::registry::{PROVIDER_ANTHROPIC, PROVIDER_OPENROUTER, ProviderConfig, get_provider};
19use crate::config::AiConfig;
20
21#[must_use]
24pub fn is_free_model(model: &str) -> bool {
25 model.ends_with(":free")
26}
27
28#[must_use]
38pub fn resolve_anthropic_credential(ai_config: &crate::config::AiConfig) -> Option<AiClient> {
39 if let Ok(Some(client)) = AiClient::from_keyring_oauth(ai_config) {
41 return Some(client);
42 }
43
44 if let Ok(Some(client)) = AiClient::from_claude_credentials(ai_config) {
46 return Some(client);
47 }
48
49 AiClient::new(PROVIDER_ANTHROPIC, ai_config).ok()
51}
52
53fn validate_openrouter_free_tier(
55 provider_name: &str,
56 model: &str,
57 config: &AiConfig,
58) -> Result<()> {
59 if provider_name == PROVIDER_OPENROUTER && !config.allow_paid_models && !is_free_model(model) {
60 anyhow::bail!(
61 "Model '{}' is not in the free tier.\n\
62 To use paid models, set `allow_paid_models = true` in your config file:\n\
63 {}\n\n\
64 Or use a free model like: google/gemma-3-12b-it:free",
65 model,
66 crate::config::config_file_path().display()
67 );
68 }
69 Ok(())
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum AuthMethod {
76 ApiKey,
78 OAuth,
80}
81
82impl std::fmt::Display for AuthMethod {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 match self {
85 AuthMethod::ApiKey => write!(f, "api-key"),
86 AuthMethod::OAuth => write!(f, "oauth"),
87 }
88 }
89}
90
91#[derive(Debug, Deserialize)]
93pub struct ClaudeCredentials {
94 pub access_token: String,
96}
97
98fn build_http_client(timeout_seconds: u64) -> Result<Client> {
102 #[cfg(not(target_arch = "wasm32"))]
103 let http = Client::builder()
104 .timeout(std::time::Duration::from_secs(timeout_seconds))
105 .build()
106 .context("Failed to create HTTP client")?;
107 #[cfg(target_arch = "wasm32")]
108 let http = Client::builder()
109 .build()
110 .context("Failed to create HTTP client")?;
111 Ok(http)
112}
113
114#[derive(Debug)]
119pub struct AiClient {
120 provider: &'static ProviderConfig,
122 http: Client,
124 api_key: SecretString,
126 model: String,
128 max_tokens: u32,
130 temperature: f32,
132 max_attempts: u32,
134 circuit_breaker: CircuitBreaker,
136 custom_guidance: Option<String>,
138 auth_method: AuthMethod,
140}
141
142impl Drop for AiClient {
143 fn drop(&mut self) {
144 use zeroize::Zeroize;
145 self.api_key.zeroize();
148 }
149}
150
151impl AiClient {
152 pub fn new(provider_name: &str, config: &AiConfig) -> Result<Self> {
170 let provider = get_provider(provider_name)
172 .with_context(|| format!("Unknown AI provider: {provider_name}"))?;
173
174 validate_openrouter_free_tier(provider_name, &config.model, config)?;
176
177 let api_key = env::var(provider.api_key_env).with_context(|| {
179 format!(
180 "Missing {} environment variable.\n\
181 Set it with: export {}=your_api_key",
182 provider.api_key_env, provider.api_key_env
183 )
184 })?;
185
186 let http = build_http_client(config.timeout_seconds)?;
188
189 Ok(Self {
190 provider,
191 http,
192 api_key: SecretString::new(api_key.into()),
193 model: config.model.clone(),
194 max_tokens: config.max_tokens,
195 temperature: config.temperature,
196 max_attempts: config.retry_max_attempts,
197 circuit_breaker: CircuitBreaker::new(
198 config.circuit_breaker_threshold,
199 config.circuit_breaker_reset_seconds,
200 ),
201 custom_guidance: config.custom_guidance.clone(),
202 auth_method: AuthMethod::ApiKey,
203 })
204 }
205
206 pub fn with_api_key(
226 provider_name: &str,
227 api_key: SecretString,
228 model_name: &str,
229 config: &AiConfig,
230 ) -> Result<Self> {
231 let provider = get_provider(provider_name)
233 .with_context(|| format!("Unknown AI provider: {provider_name}"))?;
234
235 validate_openrouter_free_tier(provider_name, model_name, config)?;
237
238 let http = build_http_client(config.timeout_seconds)?;
240
241 Ok(Self {
242 provider,
243 http,
244 api_key,
245 model: model_name.to_string(),
246 max_tokens: config.max_tokens,
247 temperature: config.temperature,
248 max_attempts: config.retry_max_attempts,
249 circuit_breaker: CircuitBreaker::new(
250 config.circuit_breaker_threshold,
251 config.circuit_breaker_reset_seconds,
252 ),
253 custom_guidance: config.custom_guidance.clone(),
254 auth_method: AuthMethod::ApiKey,
255 })
256 }
257
258 pub fn from_claude_credentials(config: &AiConfig) -> Result<Option<Self>> {
273 let Some(home) = dirs::home_dir() else {
275 return Ok(None);
276 };
277
278 let creds_path = home.join(".claude").join("credentials.json");
279
280 if !creds_path.exists() {
282 return Ok(None);
283 }
284
285 let creds_content =
287 std::fs::read_to_string(&creds_path).context("Failed to read credentials file")?;
288
289 let creds: ClaudeCredentials =
290 serde_json::from_str(&creds_content).context("Failed to parse credentials JSON")?;
291
292 if creds.access_token.is_empty() {
294 return Ok(None);
295 }
296
297 #[cfg(feature = "keyring")]
299 {
300 use keyring_core::Entry;
301 let entry = Entry::new("aptu", "anthropic_oauth_token")
302 .context("Failed to create keyring entry")?;
303 entry
304 .set_password(&creds.access_token)
305 .context("Failed to store token in keyring")?;
306 }
307
308 let client = Self::with_api_key(
310 PROVIDER_ANTHROPIC,
311 SecretString::from(creds.access_token),
312 &config.model,
313 config,
314 )?;
315
316 let mut client = client;
318 client.auth_method = AuthMethod::OAuth;
319 Ok(Some(client))
320 }
321
322 #[must_use]
329 pub fn claude_credentials_path() -> Option<std::path::PathBuf> {
330 let home = dirs::home_dir()?;
331 let creds_path = home.join(".claude").join("credentials.json");
332 if creds_path.exists() {
333 Some(creds_path)
334 } else {
335 None
336 }
337 }
338
339 pub fn from_keyring_oauth(config: &AiConfig) -> Result<Option<Self>> {
344 #[cfg(feature = "keyring")]
345 {
346 use keyring_core::Entry;
347 let entry = Entry::new("aptu", "anthropic_oauth_token")
348 .context("Failed to create keyring entry")?;
349
350 match entry.get_password() {
351 Ok(token) => {
352 let client = Self::with_api_key(
353 PROVIDER_ANTHROPIC,
354 SecretString::from(token),
355 &config.model,
356 config,
357 )?;
358
359 let mut client = client;
360 client.auth_method = AuthMethod::OAuth;
361 Ok(Some(client))
362 }
363 Err(_) => Ok(None),
364 }
365 }
366
367 #[cfg(not(feature = "keyring"))]
368 {
369 let _ = config;
370 Ok(None)
371 }
372 }
373
374 #[must_use]
376 pub fn auth_method(&self) -> AuthMethod {
377 self.auth_method
378 }
379
380 #[must_use]
382 pub fn circuit_breaker(&self) -> &CircuitBreaker {
383 &self.circuit_breaker
384 }
385}
386
387#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
388#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
389impl AiProvider for AiClient {
390 fn config(&self) -> &ProviderConfig {
391 self.provider
392 }
393
394 fn http_client(&self) -> &Client {
395 &self.http
396 }
397
398 fn api_key(&self) -> &SecretString {
399 &self.api_key
400 }
401
402 fn model(&self) -> &str {
403 &self.model
404 }
405
406 fn max_tokens(&self) -> u32 {
407 self.max_tokens
408 }
409
410 fn temperature(&self) -> f32 {
411 self.temperature
412 }
413
414 fn max_attempts(&self) -> u32 {
415 self.max_attempts
416 }
417
418 fn circuit_breaker(&self) -> Option<&super::CircuitBreaker> {
419 Some(&self.circuit_breaker)
420 }
421
422 fn custom_guidance(&self) -> Option<&str> {
423 self.custom_guidance.as_deref()
424 }
425
426 fn build_headers(&self) -> reqwest::header::HeaderMap {
427 let mut headers = reqwest::header::HeaderMap::new();
428 if let Ok(val) = "application/json".parse() {
429 headers.insert("Content-Type", val);
430 }
431
432 if self.provider.name == super::registry::PROVIDER_ANTHROPIC {
434 if let Ok(val) = self.api_key().expose_secret().parse() {
435 headers.insert("x-api-key", val);
436 }
437 if let Ok(val) = "2023-06-01".parse() {
438 headers.insert("anthropic-version", val);
439 }
440 return headers;
441 }
442
443 if self.provider.name == PROVIDER_OPENROUTER {
445 if let Ok(val) = "https://github.com/clouatre-labs/aptu".parse() {
446 headers.insert("HTTP-Referer", val);
447 }
448 if let Ok(val) = "Aptu CLI".parse() {
449 headers.insert("X-Title", val);
450 }
451 }
452
453 headers
454 }
455}
456
457#[cfg(test)]
458mod tests {
459 use super::super::registry::all_providers;
460 use super::*;
461
462 fn test_config() -> AiConfig {
463 AiConfig {
464 provider: PROVIDER_OPENROUTER.to_string(),
465 model: "test-model:free".to_string(),
466 max_tokens: 2048,
467 temperature: 0.3,
468 timeout_seconds: 30,
469 allow_paid_models: false,
470 circuit_breaker_threshold: 3,
471 circuit_breaker_reset_seconds: 60,
472 retry_max_attempts: 3,
473 tasks: None,
474 fallback: None,
475 custom_guidance: None,
476 validation_enabled: true,
477 }
478 }
479
480 #[test]
481 fn test_with_api_key_all_providers() {
482 let config = test_config();
483 for provider_config in all_providers() {
484 let result = AiClient::with_api_key(
485 provider_config.name,
486 SecretString::from("test_key"),
487 "test-model:free",
488 &config,
489 );
490 assert!(
491 result.is_ok(),
492 "Failed for provider: {}",
493 provider_config.name
494 );
495 }
496 }
497
498 #[test]
499 fn test_unknown_provider_error() {
500 let config = test_config();
501 let result = AiClient::with_api_key(
502 "nonexistent",
503 SecretString::from("key"),
504 "test-model",
505 &config,
506 );
507 assert!(result.is_err());
508 }
509
510 #[test]
511 fn test_openrouter_rejects_paid_model() {
512 let mut config = test_config();
513 config.model = "anthropic/claude-sonnet-4-6".to_string();
514 config.allow_paid_models = false;
515 let result = AiClient::with_api_key(
516 PROVIDER_OPENROUTER,
517 SecretString::from("key"),
518 "anthropic/claude-sonnet-4-6",
519 &config,
520 );
521 assert!(result.is_err());
522 }
523
524 #[test]
525 fn test_max_attempts_from_config() {
526 let mut config = test_config();
527 config.retry_max_attempts = 5;
528 let client = AiClient::with_api_key(
529 PROVIDER_OPENROUTER,
530 SecretString::from("key"),
531 "test-model:free",
532 &config,
533 )
534 .expect("should create client");
535 assert_eq!(client.max_attempts(), 5);
536 }
537
538 #[test]
539 fn test_build_headers_anthropic_has_api_key_and_version() {
540 let config = test_config();
541 let client = AiClient::with_api_key(
542 PROVIDER_ANTHROPIC,
543 SecretString::from("test_api_key"),
544 "test-model",
545 &config,
546 )
547 .expect("should create anthropic client");
548
549 let headers = client.build_headers();
550
551 let header_str = |k| headers.get(k).and_then(|v| v.to_str().ok());
552 assert_eq!(header_str("x-api-key"), Some("test_api_key"));
553 assert_eq!(header_str("anthropic-version"), Some("2023-06-01"));
554 }
555
556 #[test]
557 fn test_build_headers_non_anthropic_unaffected() {
558 let config = test_config();
559 let client = AiClient::with_api_key(
560 PROVIDER_OPENROUTER,
561 SecretString::from("test_key"),
562 "test-model:free",
563 &config,
564 )
565 .expect("should create openrouter client");
566
567 let headers = client.build_headers();
568
569 assert!(!headers.contains_key("anthropic-version"));
570 assert!(headers.contains_key("http-referer"));
571 assert!(headers.contains_key("x-title"));
572 }
573
574 #[test]
575 fn test_from_claude_credentials_missing_file() {
576 let config = test_config();
577 let result = AiClient::from_claude_credentials(&config);
578 assert!(result.is_ok());
579 assert!(result.unwrap().is_none());
580 }
581
582 #[test]
583 fn test_from_claude_credentials_malformed_json() {
584 use std::fs;
585 use std::io::Write;
586
587 let temp_dir = tempfile::tempdir().expect("should create temp dir");
588 let claude_dir = temp_dir.path().join(".claude");
589 fs::create_dir_all(&claude_dir).expect("should create .claude dir");
590
591 let creds_path = claude_dir.join("credentials.json");
592 let mut file = fs::File::create(&creds_path).expect("should create file");
593 file.write_all(b"{ invalid json }")
594 .expect("should write file");
595
596 let malformed = "{ invalid json }";
599 let result: Result<ClaudeCredentials, _> = serde_json::from_str(malformed);
600 assert!(result.is_err());
601 }
602
603 #[test]
604 fn test_from_claude_credentials_missing_access_token() {
605 let malformed = r#"{"other_field": "value"}"#;
606 let result: Result<ClaudeCredentials, _> = serde_json::from_str(malformed);
607 assert!(result.is_err());
608 }
609
610 #[test]
611 fn test_from_claude_credentials_empty_token() {
612 let empty_token = r#"{"access_token": ""}"#;
613 let creds: ClaudeCredentials = serde_json::from_str(empty_token).expect("should parse");
614 assert!(creds.access_token.is_empty());
615 }
616
617 #[test]
618 fn test_auth_method_api_key() {
619 let config = test_config();
620 let client = AiClient::with_api_key(
621 PROVIDER_ANTHROPIC,
622 SecretString::from("test_key"),
623 "test-model",
624 &config,
625 )
626 .expect("should create client");
627 assert_eq!(client.auth_method(), AuthMethod::ApiKey);
628 }
629}