Skip to main content

aptu_core/ai/
client.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Generic AI client for all registered providers.
4//!
5//! Provides a single `AiClient` struct that works with any AI provider
6//! registered in the provider registry. See [`super::registry`] for available providers.
7
8use 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/// Checks if a model is in the free tier (no cost).
22/// Free models on `OpenRouter` always have the `:free` suffix.
23#[must_use]
24pub fn is_free_model(model: &str) -> bool {
25    model.ends_with(":free")
26}
27
28/// Resolves Anthropic credentials with OAuth fallback.
29///
30/// For the Anthropic provider, attempts to use Claude OAuth credentials in this order:
31/// 1. Existing token in OS keyring
32/// 2. ~/.claude/credentials.json file
33/// 3. Environment variable (fallback)
34///
35/// Returns `Some(client)` if credentials were found via OAuth or env var,
36/// `None` if no credentials were available.
37#[must_use]
38pub fn resolve_anthropic_credential(ai_config: &crate::config::AiConfig) -> Option<AiClient> {
39    // Try keyring first
40    if let Ok(Some(client)) = AiClient::from_keyring_oauth(ai_config) {
41        return Some(client);
42    }
43
44    // Try credentials file
45    if let Ok(Some(client)) = AiClient::from_claude_credentials(ai_config) {
46        return Some(client);
47    }
48
49    // Fall back to environment variable
50    AiClient::new(PROVIDER_ANTHROPIC, ai_config).ok()
51}
52
53/// Validates model against `OpenRouter` free-tier policy.
54fn 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/// Authentication method used by the AI client.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum AuthMethod {
76    /// API key from environment variable.
77    ApiKey,
78    /// OAuth token from Claude credentials file.
79    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/// Claude credentials from ~/.claude/credentials.json.
92#[derive(Debug, Deserialize)]
93pub struct ClaudeCredentials {
94    /// OAuth access token.
95    pub access_token: String,
96}
97
98/// Creates an HTTP client with timeout. On native targets, the request timeout
99/// is set on the client; on wasm32, the browser's fetch API manages timeouts
100/// independently and reqwest's `timeout()` is unavailable.
101fn 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/// Generic AI client for all providers.
115///
116/// Holds HTTP client, API key, and model configuration for reuse across multiple requests.
117/// Uses the provider registry to get provider-specific configuration.
118#[derive(Debug)]
119pub struct AiClient {
120    /// Provider configuration from registry.
121    provider: &'static ProviderConfig,
122    /// HTTP client with configured timeout.
123    http: Client,
124    /// API key for provider authentication.
125    api_key: SecretString,
126    /// Model name (e.g., "mistralai/mistral-small-2603").
127    model: String,
128    /// Maximum tokens for API responses.
129    max_tokens: u32,
130    /// Temperature for API requests.
131    temperature: f32,
132    /// Maximum retry attempts for rate-limited requests.
133    max_attempts: u32,
134    /// Circuit breaker for resilience.
135    circuit_breaker: CircuitBreaker,
136    /// Optional custom guidance from config to inject into system prompts.
137    custom_guidance: Option<String>,
138    /// `OpenRouter` data collection setting.
139    openrouter_data_collection: String,
140    /// `OpenRouter` Zero Data Retention requirement.
141    openrouter_zdr: bool,
142    /// Authentication method used.
143    auth_method: AuthMethod,
144}
145
146impl Drop for AiClient {
147    fn drop(&mut self) {
148        use zeroize::Zeroize;
149        // Safety: SecretString wraps String, which implements Zeroize.
150        // Calling zeroize() overwrites the backing buffer before deallocation.
151        self.api_key.zeroize();
152    }
153}
154
155impl AiClient {
156    /// Creates a new AI client from configuration.
157    ///
158    /// Validates the model against cost control settings and fetches the API key
159    /// from the environment.
160    ///
161    /// # Arguments
162    ///
163    /// * `provider_name` - Name of the provider (e.g., "openrouter", "gemini")
164    /// * `config` - AI configuration with model, timeout, and cost control settings
165    ///
166    /// # Errors
167    ///
168    /// Returns an error if:
169    /// - Provider is not found in registry
170    /// - Model is not in free tier and `allow_paid_models` is false (for `OpenRouter`)
171    /// - API key environment variable is not set
172    /// - HTTP client creation fails
173    pub fn new(provider_name: &str, config: &AiConfig) -> Result<Self> {
174        // Look up provider in registry
175        let provider = get_provider(provider_name)
176            .with_context(|| format!("Unknown AI provider: {provider_name}"))?;
177
178        // Validate model against cost control (OpenRouter-specific)
179        validate_openrouter_free_tier(provider_name, &config.model, config)?;
180
181        // Get API key from environment
182        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        // Create HTTP client with timeout (timeout() is native-only; wasm32 uses fetch API)
191        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    /// Creates a new AI client with a provided API key and validates the model exists.
213    ///
214    /// This constructor validates that the model exists via the runtime model registry
215    /// before creating the client. It allows callers to provide an API key directly,
216    /// enabling multi-platform credential resolution (e.g., from iOS keychain via FFI).
217    ///
218    /// # Arguments
219    ///
220    /// * `provider_name` - Name of the provider (e.g., "openrouter", "gemini")
221    /// * `api_key` - API key as a `SecretString`
222    /// * `model_name` - Model name to use (e.g., "gemini-3.1-flash-lite")
223    /// * `config` - AI configuration with timeout and cost control settings
224    ///
225    /// # Errors
226    ///
227    /// Returns an error if:
228    /// - Provider is not found in registry
229    /// - Model is not in free tier and `allow_paid_models` is false (for `OpenRouter`)
230    /// - HTTP client creation fails
231    pub fn with_api_key(
232        provider_name: &str,
233        api_key: SecretString,
234        model_name: &str,
235        config: &AiConfig,
236    ) -> Result<Self> {
237        // Look up provider in registry
238        let provider = get_provider(provider_name)
239            .with_context(|| format!("Unknown AI provider: {provider_name}"))?;
240
241        // Validate model against cost control (OpenRouter-specific)
242        validate_openrouter_free_tier(provider_name, model_name, config)?;
243
244        // Create HTTP client with timeout (timeout() is native-only; wasm32 uses fetch API)
245        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    /// Creates a new AI client from Claude credentials file (~/.claude/credentials.json).
267    ///
268    /// Reads the credentials file, extracts the access token, stores it in the OS keyring,
269    /// and returns an `AiClient` configured for the Anthropic provider.
270    ///
271    /// # Arguments
272    ///
273    /// * `config` - AI configuration with timeout and cost control settings
274    ///
275    /// # Returns
276    ///
277    /// Returns `Ok(Some(AiClient))` if credentials are found and valid,
278    /// `Ok(None)` if the credentials file is missing or invalid,
279    /// or an error if keyring operations fail.
280    pub fn from_claude_credentials(config: &AiConfig) -> Result<Option<Self>> {
281        // Resolve credentials file path
282        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        // Check if file exists
289        if !creds_path.exists() {
290            return Ok(None);
291        }
292
293        // Read and parse credentials file
294        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        // Validate token is not empty
301        if creds.access_token.is_empty() {
302            return Ok(None);
303        }
304
305        // Store token in keyring
306        #[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        // Create client with the token
317        let client = Self::with_api_key(
318            PROVIDER_ANTHROPIC,
319            SecretString::from(creds.access_token),
320            &config.model,
321            config,
322        )?;
323
324        // Mark as OAuth
325        let mut client = client;
326        client.auth_method = AuthMethod::OAuth;
327        Ok(Some(client))
328    }
329
330    /// Returns the path to the Claude credentials file if it exists.
331    ///
332    /// This helper centralizes the path resolution logic for ~/.claude/credentials.json,
333    /// keeping the CLI command layer thin and avoiding duplicate path construction.
334    ///
335    /// Returns `Some(path)` if the file exists, `None` otherwise.
336    #[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    /// Attempts to retrieve a Claude OAuth token from the OS keyring.
348    ///
349    /// Returns `Ok(Some(AiClient))` if a token is found in the keyring,
350    /// `Ok(None)` if no token is stored, or an error if keyring operations fail.
351    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    /// Returns the authentication method used by this client.
383    #[must_use]
384    pub fn auth_method(&self) -> AuthMethod {
385        self.auth_method
386    }
387
388    /// Get the circuit breaker for this client.
389    #[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        // Anthropic-specific headers
441        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        // OpenRouter-specific headers
452        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        // Temporarily override home_dir for this test
618        // Since we can't easily mock dirs::home_dir, we'll test the parsing logic directly
619        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}