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    /// Authentication method used.
139    auth_method: AuthMethod,
140}
141
142impl Drop for AiClient {
143    fn drop(&mut self) {
144        use zeroize::Zeroize;
145        // Safety: SecretString wraps String, which implements Zeroize.
146        // Calling zeroize() overwrites the backing buffer before deallocation.
147        self.api_key.zeroize();
148    }
149}
150
151impl AiClient {
152    /// Creates a new AI client from configuration.
153    ///
154    /// Validates the model against cost control settings and fetches the API key
155    /// from the environment.
156    ///
157    /// # Arguments
158    ///
159    /// * `provider_name` - Name of the provider (e.g., "openrouter", "gemini")
160    /// * `config` - AI configuration with model, timeout, and cost control settings
161    ///
162    /// # Errors
163    ///
164    /// Returns an error if:
165    /// - Provider is not found in registry
166    /// - Model is not in free tier and `allow_paid_models` is false (for `OpenRouter`)
167    /// - API key environment variable is not set
168    /// - HTTP client creation fails
169    pub fn new(provider_name: &str, config: &AiConfig) -> Result<Self> {
170        // Look up provider in registry
171        let provider = get_provider(provider_name)
172            .with_context(|| format!("Unknown AI provider: {provider_name}"))?;
173
174        // Validate model against cost control (OpenRouter-specific)
175        validate_openrouter_free_tier(provider_name, &config.model, config)?;
176
177        // Get API key from environment
178        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        // Create HTTP client with timeout (timeout() is native-only; wasm32 uses fetch API)
187        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    /// Creates a new AI client with a provided API key and validates the model exists.
207    ///
208    /// This constructor validates that the model exists via the runtime model registry
209    /// before creating the client. It allows callers to provide an API key directly,
210    /// enabling multi-platform credential resolution (e.g., from iOS keychain via FFI).
211    ///
212    /// # Arguments
213    ///
214    /// * `provider_name` - Name of the provider (e.g., "openrouter", "gemini")
215    /// * `api_key` - API key as a `SecretString`
216    /// * `model_name` - Model name to use (e.g., "gemini-3.1-flash-lite")
217    /// * `config` - AI configuration with timeout and cost control settings
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if:
222    /// - Provider is not found in registry
223    /// - Model is not in free tier and `allow_paid_models` is false (for `OpenRouter`)
224    /// - HTTP client creation fails
225    pub fn with_api_key(
226        provider_name: &str,
227        api_key: SecretString,
228        model_name: &str,
229        config: &AiConfig,
230    ) -> Result<Self> {
231        // Look up provider in registry
232        let provider = get_provider(provider_name)
233            .with_context(|| format!("Unknown AI provider: {provider_name}"))?;
234
235        // Validate model against cost control (OpenRouter-specific)
236        validate_openrouter_free_tier(provider_name, model_name, config)?;
237
238        // Create HTTP client with timeout (timeout() is native-only; wasm32 uses fetch API)
239        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    /// Creates a new AI client from Claude credentials file (~/.claude/credentials.json).
259    ///
260    /// Reads the credentials file, extracts the access token, stores it in the OS keyring,
261    /// and returns an `AiClient` configured for the Anthropic provider.
262    ///
263    /// # Arguments
264    ///
265    /// * `config` - AI configuration with timeout and cost control settings
266    ///
267    /// # Returns
268    ///
269    /// Returns `Ok(Some(AiClient))` if credentials are found and valid,
270    /// `Ok(None)` if the credentials file is missing or invalid,
271    /// or an error if keyring operations fail.
272    pub fn from_claude_credentials(config: &AiConfig) -> Result<Option<Self>> {
273        // Resolve credentials file path
274        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        // Check if file exists
281        if !creds_path.exists() {
282            return Ok(None);
283        }
284
285        // Read and parse credentials file
286        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        // Validate token is not empty
293        if creds.access_token.is_empty() {
294            return Ok(None);
295        }
296
297        // Store token in keyring
298        #[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        // Create client with the token
309        let client = Self::with_api_key(
310            PROVIDER_ANTHROPIC,
311            SecretString::from(creds.access_token),
312            &config.model,
313            config,
314        )?;
315
316        // Mark as OAuth
317        let mut client = client;
318        client.auth_method = AuthMethod::OAuth;
319        Ok(Some(client))
320    }
321
322    /// Returns the path to the Claude credentials file if it exists.
323    ///
324    /// This helper centralizes the path resolution logic for ~/.claude/credentials.json,
325    /// keeping the CLI command layer thin and avoiding duplicate path construction.
326    ///
327    /// Returns `Some(path)` if the file exists, `None` otherwise.
328    #[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    /// Attempts to retrieve a Claude OAuth token from the OS keyring.
340    ///
341    /// Returns `Ok(Some(AiClient))` if a token is found in the keyring,
342    /// `Ok(None)` if no token is stored, or an error if keyring operations fail.
343    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    /// Returns the authentication method used by this client.
375    #[must_use]
376    pub fn auth_method(&self) -> AuthMethod {
377        self.auth_method
378    }
379
380    /// Get the circuit breaker for this client.
381    #[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        // Anthropic-specific headers
433        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        // OpenRouter-specific headers
444        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        // Temporarily override home_dir for this test
597        // Since we can't easily mock dirs::home_dir, we'll test the parsing logic directly
598        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}