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 openrouter_data_collection: String,
140 openrouter_zdr: bool,
142 auth_method: AuthMethod,
144}
145
146impl Drop for AiClient {
147 fn drop(&mut self) {
148 use zeroize::Zeroize;
149 self.api_key.zeroize();
152 }
153}
154
155impl AiClient {
156 pub fn new(provider_name: &str, config: &AiConfig) -> Result<Self> {
174 let provider = get_provider(provider_name)
176 .with_context(|| format!("Unknown AI provider: {provider_name}"))?;
177
178 validate_openrouter_free_tier(provider_name, &config.model, config)?;
180
181 let api_key = env::var(provider.api_key_env).with_context(|| {
183 format!(
184 "Missing {} environment variable.\n\
185 Set it with: export {}=your_api_key",
186 provider.api_key_env, provider.api_key_env
187 )
188 })?;
189
190 let http = build_http_client(config.timeout_seconds)?;
192
193 Ok(Self {
194 provider,
195 http,
196 api_key: SecretString::new(api_key.into()),
197 model: config.model.clone(),
198 max_tokens: config.max_tokens,
199 temperature: config.temperature,
200 max_attempts: config.retry_max_attempts,
201 circuit_breaker: CircuitBreaker::new(
202 config.circuit_breaker_threshold,
203 config.circuit_breaker_reset_seconds,
204 ),
205 custom_guidance: config.custom_guidance.clone(),
206 openrouter_data_collection: config.openrouter_data_collection.clone(),
207 openrouter_zdr: config.openrouter_zdr,
208 auth_method: AuthMethod::ApiKey,
209 })
210 }
211
212 pub fn with_api_key(
232 provider_name: &str,
233 api_key: SecretString,
234 model_name: &str,
235 config: &AiConfig,
236 ) -> Result<Self> {
237 let provider = get_provider(provider_name)
239 .with_context(|| format!("Unknown AI provider: {provider_name}"))?;
240
241 validate_openrouter_free_tier(provider_name, model_name, config)?;
243
244 let http = build_http_client(config.timeout_seconds)?;
246
247 Ok(Self {
248 provider,
249 http,
250 api_key,
251 model: model_name.to_string(),
252 max_tokens: config.max_tokens,
253 temperature: config.temperature,
254 max_attempts: config.retry_max_attempts,
255 circuit_breaker: CircuitBreaker::new(
256 config.circuit_breaker_threshold,
257 config.circuit_breaker_reset_seconds,
258 ),
259 custom_guidance: config.custom_guidance.clone(),
260 openrouter_data_collection: config.openrouter_data_collection.clone(),
261 openrouter_zdr: config.openrouter_zdr,
262 auth_method: AuthMethod::ApiKey,
263 })
264 }
265
266 pub fn from_claude_credentials(config: &AiConfig) -> Result<Option<Self>> {
281 let Some(home) = dirs::home_dir() else {
283 return Ok(None);
284 };
285
286 let creds_path = home.join(".claude").join("credentials.json");
287
288 if !creds_path.exists() {
290 return Ok(None);
291 }
292
293 let creds_content =
295 std::fs::read_to_string(&creds_path).context("Failed to read credentials file")?;
296
297 let creds: ClaudeCredentials =
298 serde_json::from_str(&creds_content).context("Failed to parse credentials JSON")?;
299
300 if creds.access_token.is_empty() {
302 return Ok(None);
303 }
304
305 #[cfg(feature = "keyring")]
307 {
308 use keyring_core::Entry;
309 let entry = Entry::new("aptu", "anthropic_oauth_token")
310 .context("Failed to create keyring entry")?;
311 entry
312 .set_password(&creds.access_token)
313 .context("Failed to store token in keyring")?;
314 }
315
316 let client = Self::with_api_key(
318 PROVIDER_ANTHROPIC,
319 SecretString::from(creds.access_token),
320 &config.model,
321 config,
322 )?;
323
324 let mut client = client;
326 client.auth_method = AuthMethod::OAuth;
327 Ok(Some(client))
328 }
329
330 #[must_use]
337 pub fn claude_credentials_path() -> Option<std::path::PathBuf> {
338 let home = dirs::home_dir()?;
339 let creds_path = home.join(".claude").join("credentials.json");
340 if creds_path.exists() {
341 Some(creds_path)
342 } else {
343 None
344 }
345 }
346
347 pub fn from_keyring_oauth(config: &AiConfig) -> Result<Option<Self>> {
352 #[cfg(feature = "keyring")]
353 {
354 use keyring_core::Entry;
355 let entry = Entry::new("aptu", "anthropic_oauth_token")
356 .context("Failed to create keyring entry")?;
357
358 match entry.get_password() {
359 Ok(token) => {
360 let client = Self::with_api_key(
361 PROVIDER_ANTHROPIC,
362 SecretString::from(token),
363 &config.model,
364 config,
365 )?;
366
367 let mut client = client;
368 client.auth_method = AuthMethod::OAuth;
369 Ok(Some(client))
370 }
371 Err(_) => Ok(None),
372 }
373 }
374
375 #[cfg(not(feature = "keyring"))]
376 {
377 let _ = config;
378 Ok(None)
379 }
380 }
381
382 #[must_use]
384 pub fn auth_method(&self) -> AuthMethod {
385 self.auth_method
386 }
387
388 #[must_use]
390 pub fn circuit_breaker(&self) -> &CircuitBreaker {
391 &self.circuit_breaker
392 }
393}
394
395#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
396#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
397impl AiProvider for AiClient {
398 fn config(&self) -> &ProviderConfig {
399 self.provider
400 }
401
402 fn http_client(&self) -> &Client {
403 &self.http
404 }
405
406 fn api_key(&self) -> &SecretString {
407 &self.api_key
408 }
409
410 fn model(&self) -> &str {
411 &self.model
412 }
413
414 fn max_tokens(&self) -> u32 {
415 self.max_tokens
416 }
417
418 fn temperature(&self) -> f32 {
419 self.temperature
420 }
421
422 fn max_attempts(&self) -> u32 {
423 self.max_attempts
424 }
425
426 fn circuit_breaker(&self) -> Option<&super::CircuitBreaker> {
427 Some(&self.circuit_breaker)
428 }
429
430 fn custom_guidance(&self) -> Option<&str> {
431 self.custom_guidance.as_deref()
432 }
433
434 fn build_headers(&self) -> reqwest::header::HeaderMap {
435 let mut headers = reqwest::header::HeaderMap::new();
436 if let Ok(val) = "application/json".parse() {
437 headers.insert("Content-Type", val);
438 }
439
440 if self.provider.name == super::registry::PROVIDER_ANTHROPIC {
442 if let Ok(val) = self.api_key().expose_secret().parse() {
443 headers.insert("x-api-key", val);
444 }
445 if let Ok(val) = "2023-06-01".parse() {
446 headers.insert("anthropic-version", val);
447 }
448 return headers;
449 }
450
451 if self.provider.name == PROVIDER_OPENROUTER {
453 if let Ok(val) = "https://github.com/clouatre-labs/aptu".parse() {
454 headers.insert("HTTP-Referer", val);
455 }
456 if let Ok(val) = "Aptu CLI".parse() {
457 headers.insert("X-Title", val);
458 }
459 }
460
461 headers
462 }
463
464 fn provider_body_extensions(&self) -> Option<serde_json::Value> {
465 if self.provider.name == PROVIDER_OPENROUTER {
466 Some(serde_json::json!({
467 "data_collection": &self.openrouter_data_collection,
468 "zdr": self.openrouter_zdr,
469 }))
470 } else {
471 None
472 }
473 }
474}
475
476#[cfg(test)]
477mod tests {
478 use super::super::registry::all_providers;
479 use super::*;
480
481 fn test_config() -> AiConfig {
482 AiConfig {
483 provider: PROVIDER_OPENROUTER.to_string(),
484 model: "test-model:free".to_string(),
485 max_tokens: 2048,
486 temperature: 0.3,
487 timeout_seconds: 30,
488 allow_paid_models: false,
489 circuit_breaker_threshold: 3,
490 circuit_breaker_reset_seconds: 60,
491 retry_max_attempts: 3,
492 tasks: None,
493 fallback: None,
494 custom_guidance: None,
495 validation_enabled: true,
496 openrouter_data_collection: "deny".to_string(),
497 openrouter_zdr: true,
498 }
499 }
500
501 #[test]
502 fn test_with_api_key_all_providers() {
503 let config = test_config();
504 for provider_config in all_providers() {
505 let result = AiClient::with_api_key(
506 provider_config.name,
507 SecretString::from("test_key"),
508 "test-model:free",
509 &config,
510 );
511 assert!(
512 result.is_ok(),
513 "Failed for provider: {}",
514 provider_config.name
515 );
516 }
517 }
518
519 #[test]
520 fn test_unknown_provider_error() {
521 let config = test_config();
522 let result = AiClient::with_api_key(
523 "nonexistent",
524 SecretString::from("key"),
525 "test-model",
526 &config,
527 );
528 assert!(result.is_err());
529 }
530
531 #[test]
532 fn test_openrouter_rejects_paid_model() {
533 let mut config = test_config();
534 config.model = "anthropic/claude-sonnet-4-6".to_string();
535 config.allow_paid_models = false;
536 let result = AiClient::with_api_key(
537 PROVIDER_OPENROUTER,
538 SecretString::from("key"),
539 "anthropic/claude-sonnet-4-6",
540 &config,
541 );
542 assert!(result.is_err());
543 }
544
545 #[test]
546 fn test_max_attempts_from_config() {
547 let mut config = test_config();
548 config.retry_max_attempts = 5;
549 let client = AiClient::with_api_key(
550 PROVIDER_OPENROUTER,
551 SecretString::from("key"),
552 "test-model:free",
553 &config,
554 )
555 .expect("should create client");
556 assert_eq!(client.max_attempts(), 5);
557 }
558
559 #[test]
560 fn test_build_headers_anthropic_has_api_key_and_version() {
561 let config = test_config();
562 let client = AiClient::with_api_key(
563 PROVIDER_ANTHROPIC,
564 SecretString::from("test_api_key"),
565 "test-model",
566 &config,
567 )
568 .expect("should create anthropic client");
569
570 let headers = client.build_headers();
571
572 let header_str = |k| headers.get(k).and_then(|v| v.to_str().ok());
573 assert_eq!(header_str("x-api-key"), Some("test_api_key"));
574 assert_eq!(header_str("anthropic-version"), Some("2023-06-01"));
575 }
576
577 #[test]
578 fn test_build_headers_non_anthropic_unaffected() {
579 let config = test_config();
580 let client = AiClient::with_api_key(
581 PROVIDER_OPENROUTER,
582 SecretString::from("test_key"),
583 "test-model:free",
584 &config,
585 )
586 .expect("should create openrouter client");
587
588 let headers = client.build_headers();
589
590 assert!(!headers.contains_key("anthropic-version"));
591 assert!(headers.contains_key("http-referer"));
592 assert!(headers.contains_key("x-title"));
593 }
594
595 #[test]
596 fn test_from_claude_credentials_missing_file() {
597 let config = test_config();
598 let result = AiClient::from_claude_credentials(&config);
599 assert!(result.is_ok());
600 assert!(result.unwrap().is_none());
601 }
602
603 #[test]
604 fn test_from_claude_credentials_malformed_json() {
605 use std::fs;
606 use std::io::Write;
607
608 let temp_dir = tempfile::tempdir().expect("should create temp dir");
609 let claude_dir = temp_dir.path().join(".claude");
610 fs::create_dir_all(&claude_dir).expect("should create .claude dir");
611
612 let creds_path = claude_dir.join("credentials.json");
613 let mut file = fs::File::create(&creds_path).expect("should create file");
614 file.write_all(b"{ invalid json }")
615 .expect("should write file");
616
617 let malformed = "{ invalid json }";
620 let result: Result<ClaudeCredentials, _> = serde_json::from_str(malformed);
621 assert!(result.is_err());
622 }
623
624 #[test]
625 fn test_from_claude_credentials_missing_access_token() {
626 let malformed = r#"{"other_field": "value"}"#;
627 let result: Result<ClaudeCredentials, _> = serde_json::from_str(malformed);
628 assert!(result.is_err());
629 }
630
631 #[test]
632 fn test_from_claude_credentials_empty_token() {
633 let empty_token = r#"{"access_token": ""}"#;
634 let creds: ClaudeCredentials = serde_json::from_str(empty_token).expect("should parse");
635 assert!(creds.access_token.is_empty());
636 }
637
638 #[test]
639 fn test_auth_method_api_key() {
640 let config = test_config();
641 let client = AiClient::with_api_key(
642 PROVIDER_ANTHROPIC,
643 SecretString::from("test_key"),
644 "test-model",
645 &config,
646 )
647 .expect("should create client");
648 assert_eq!(client.auth_method(), AuthMethod::ApiKey);
649 }
650
651 #[test]
652 fn test_provider_body_extensions_openrouter() {
653 let config = test_config();
654 let client = AiClient::with_api_key(
655 PROVIDER_OPENROUTER,
656 SecretString::from("test_key"),
657 "test-model:free",
658 &config,
659 )
660 .expect("should create openrouter client");
661
662 let ext = client.provider_body_extensions();
663 assert!(ext.is_some());
664 let val = ext.unwrap();
665 assert_eq!(val["data_collection"], "deny");
666 assert_eq!(val["zdr"], true);
667 }
668
669 #[test]
670 fn test_provider_body_extensions_non_openrouter() {
671 let config = test_config();
672 let client = AiClient::with_api_key(
673 PROVIDER_ANTHROPIC,
674 SecretString::from("test_key"),
675 "test-model",
676 &config,
677 )
678 .expect("should create anthropic client");
679
680 let ext = client.provider_body_extensions();
681 assert!(ext.is_none());
682 }
683}