cstats_core/api/
client.rs

1//! API client implementation
2
3use chrono::{DateTime, Datelike, Timelike, Utc};
4use reqwest::{
5    header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT},
6    Client,
7};
8use std::sync::Arc;
9use std::time::Duration;
10
11use crate::{
12    cache::FileCache,
13    config::{ApiConfig, CacheConfig},
14    Error, Result,
15};
16
17use super::{
18    AnthropicConfig, AnthropicUsageStats, CostBreakdown, HttpClient, MetricData, RateLimitInfo,
19    StatisticsData, UsageSummary,
20};
21
22/// High-level API client for cstats operations
23#[derive(Debug, Clone)]
24pub struct ApiClient {
25    http: HttpClient,
26    anthropic: Option<AnthropicApiClient>,
27}
28
29impl ApiClient {
30    /// Create a new API client
31    pub fn new(config: ApiConfig) -> Result<Self> {
32        let http = HttpClient::new(config.clone())?;
33
34        let anthropic = if let Some(anthropic_config) = config.anthropic {
35            Some(AnthropicApiClient::new(anthropic_config)?.with_usage_tracking())
36        } else {
37            None
38        };
39
40        Ok(Self { http, anthropic })
41    }
42
43    /// Create API client with cache enabled
44    pub fn with_cache(config: ApiConfig, cache_config: CacheConfig) -> Result<Self> {
45        let http = HttpClient::new(config.clone())?;
46
47        let cache = Arc::new(FileCache::new(cache_config));
48
49        let anthropic = if let Some(anthropic_config) = config.anthropic {
50            // Only create client if API key is not empty
51            if !anthropic_config.api_key.is_empty() {
52                Some(
53                    AnthropicApiClient::new(anthropic_config)?
54                        .with_cache(cache.clone())
55                        .with_usage_tracking(),
56                )
57            } else {
58                None
59            }
60        } else {
61            None
62        };
63
64        Ok(Self { http, anthropic })
65    }
66
67    /// Create API client from loaded configuration with cache
68    pub async fn from_config_with_cache(config: crate::config::Config) -> Result<Self> {
69        let api_config = config.api;
70        let cache_config = config.cache;
71
72        let client = Self::with_cache(api_config, cache_config)?;
73
74        // Initialize cache directory if anthropic client is configured
75        if let Some(anthropic_client) = &client.anthropic {
76            if let Some(cache) = &anthropic_client.cache {
77                cache.init().await?;
78            }
79        }
80
81        Ok(client)
82    }
83
84    /// Create API client from environment variables with cache (loads config file with env overrides)
85    pub async fn from_env_with_cache() -> Result<Self> {
86        // Load config from file first, then apply env overrides
87        let config = crate::config::Config::load().await?;
88        Self::from_config_with_cache(config).await
89    }
90
91    /// Get the Anthropic API client if configured
92    pub fn anthropic(&self) -> Option<&AnthropicApiClient> {
93        self.anthropic.as_ref()
94    }
95
96    /// Fetch Claude Code usage statistics from Anthropic API
97    pub async fn fetch_claude_usage_stats(
98        &self,
99        start_time: DateTime<Utc>,
100        end_time: DateTime<Utc>,
101    ) -> Result<AnthropicUsageStats> {
102        let anthropic_client = self
103            .anthropic
104            .as_ref()
105            .ok_or_else(|| Error::config("Anthropic API not configured"))?;
106
107        anthropic_client
108            .fetch_usage_stats(start_time, end_time)
109            .await
110    }
111
112    /// Fetch rate limit information from Anthropic API
113    pub async fn fetch_rate_limit_info(&self) -> Result<RateLimitInfo> {
114        let anthropic_client = self
115            .anthropic
116            .as_ref()
117            .ok_or_else(|| Error::config("Anthropic API not configured"))?;
118
119        anthropic_client.fetch_rate_limit_info().await
120    }
121
122    /// Fetch billing information from Anthropic API
123    pub async fn fetch_billing_info(
124        &self,
125        start_time: DateTime<Utc>,
126        end_time: DateTime<Utc>,
127    ) -> Result<CostBreakdown> {
128        let anthropic_client = self
129            .anthropic
130            .as_ref()
131            .ok_or_else(|| Error::config("Anthropic API not configured"))?;
132
133        anthropic_client
134            .fetch_billing_info(start_time, end_time)
135            .await
136    }
137
138    /// Fetch usage stats for the last 24 hours
139    pub async fn fetch_daily_usage_stats(&self) -> Result<AnthropicUsageStats> {
140        let end_time = Utc::now();
141        let start_time = end_time - chrono::Duration::days(1);
142        self.fetch_claude_usage_stats(start_time, end_time).await
143    }
144
145    /// Fetch usage stats for the last 7 days
146    pub async fn fetch_weekly_usage_stats(&self) -> Result<AnthropicUsageStats> {
147        let end_time = Utc::now();
148        let start_time = end_time - chrono::Duration::days(7);
149        self.fetch_claude_usage_stats(start_time, end_time).await
150    }
151
152    /// Fetch usage stats for the last 30 days
153    pub async fn fetch_monthly_usage_stats(&self) -> Result<AnthropicUsageStats> {
154        let end_time = Utc::now();
155        let start_time = end_time - chrono::Duration::days(30);
156        self.fetch_claude_usage_stats(start_time, end_time).await
157    }
158
159    /// Fetch usage stats for the current month
160    pub async fn fetch_current_month_usage_stats(&self) -> Result<AnthropicUsageStats> {
161        let now = Utc::now();
162        let start_time = now
163            .with_day(1)
164            .unwrap()
165            .with_hour(0)
166            .unwrap()
167            .with_minute(0)
168            .unwrap()
169            .with_second(0)
170            .unwrap();
171        self.fetch_claude_usage_stats(start_time, now).await
172    }
173
174    /// Fetch billing info for the current month
175    pub async fn fetch_current_month_billing(&self) -> Result<CostBreakdown> {
176        let now = Utc::now();
177        let start_time = now
178            .with_day(1)
179            .unwrap()
180            .with_hour(0)
181            .unwrap()
182            .with_minute(0)
183            .unwrap()
184            .with_second(0)
185            .unwrap();
186        self.fetch_billing_info(start_time, now).await
187    }
188
189    /// Get a comprehensive usage summary
190    pub async fn get_usage_summary(&self) -> Result<UsageSummary> {
191        let _anthropic_client = self
192            .anthropic
193            .as_ref()
194            .ok_or_else(|| Error::config("Anthropic API not configured"))?;
195
196        let (daily_stats, weekly_stats, monthly_stats, rate_limit) = tokio::try_join!(
197            self.fetch_daily_usage_stats(),
198            self.fetch_weekly_usage_stats(),
199            self.fetch_monthly_usage_stats(),
200            self.fetch_rate_limit_info()
201        )?;
202
203        Ok(UsageSummary {
204            daily: daily_stats,
205            weekly: weekly_stats,
206            monthly: monthly_stats,
207            rate_limit,
208            timestamp: Utc::now(),
209        })
210    }
211
212    /// Submit statistics data to the API
213    pub async fn submit_statistics(&self, data: &StatisticsData) -> Result<()> {
214        if let Some(base_url) = &self.http.config().base_url {
215            let url = format!("{}/api/v1/statistics", base_url);
216            let response = self.http.client().post(&url).json(data).send().await?;
217
218            if !response.status().is_success() {
219                return Err(Error::api(format!(
220                    "Failed to submit statistics: {}",
221                    response.status()
222                )));
223            }
224        }
225
226        Ok(())
227    }
228
229    /// Retrieve metrics from the API
230    pub async fn get_metrics(&self, query: &str) -> Result<Vec<MetricData>> {
231        if let Some(base_url) = &self.http.config().base_url {
232            let url = format!("{}/api/v1/metrics?q={}", base_url, query);
233            let response = self.http.client().get(&url).send().await?;
234
235            if !response.status().is_success() {
236                return Err(Error::api(format!(
237                    "Failed to get metrics: {}",
238                    response.status()
239                )));
240            }
241
242            let metrics: Vec<MetricData> = response.json().await?;
243            return Ok(metrics);
244        }
245
246        // Return empty vec if no base URL configured
247        Ok(vec![])
248    }
249
250    /// Health check for the API
251    pub async fn health_check(&self) -> Result<bool> {
252        if let Some(base_url) = &self.http.config().base_url {
253            let url = format!("{}/health", base_url);
254            let response = self.http.client().get(&url).send().await?;
255
256            Ok(response.status().is_success())
257        } else {
258            Ok(true) // Consider healthy if no API configured
259        }
260    }
261}
262
263/// Anthropic API client for Claude Code usage statistics
264///
265/// Note: This client provides a wrapper around Anthropic's API with mock implementations
266/// for usage statistics, billing, and rate limit endpoints that are not publicly available.
267/// The client can still be used for actual API calls to supported endpoints.
268#[derive(Debug, Clone)]
269pub struct AnthropicApiClient {
270    client: Client,
271    config: AnthropicConfig,
272    cache: Option<Arc<FileCache>>,
273    usage_tracker: Option<Arc<super::LocalUsageTracker>>,
274}
275
276impl AnthropicApiClient {
277    /// Create a new Anthropic API client
278    pub fn new(config: AnthropicConfig) -> Result<Self> {
279        let mut headers = HeaderMap::new();
280        headers.insert(
281            AUTHORIZATION,
282            HeaderValue::from_str(&format!("Bearer {}", config.api_key))
283                .map_err(|e| Error::config(format!("Invalid API key format: {}", e)))?,
284        );
285        headers.insert(USER_AGENT, HeaderValue::from_static("cstats-client/1.0.0"));
286        headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01"));
287
288        let client = Client::builder()
289            .timeout(Duration::from_secs(config.timeout_seconds))
290            .default_headers(headers)
291            .build()
292            .map_err(Error::Http)?;
293
294        Ok(Self {
295            client,
296            config,
297            cache: None,
298            usage_tracker: None,
299        })
300    }
301
302    /// Create client from environment variables
303    pub fn from_env() -> Result<Self> {
304        let api_key = std::env::var("ANTHROPIC_API_KEY")
305            .map_err(|_| Error::config("ANTHROPIC_API_KEY environment variable not set"))?;
306
307        let mut config = AnthropicConfig {
308            api_key,
309            ..Default::default()
310        };
311
312        // Override defaults with environment variables if present
313        if let Ok(base_url) = std::env::var("ANTHROPIC_BASE_URL") {
314            config.base_url = base_url;
315        }
316
317        if let Ok(timeout) = std::env::var("ANTHROPIC_TIMEOUT_SECONDS") {
318            if let Ok(timeout_val) = timeout.parse() {
319                config.timeout_seconds = timeout_val;
320            }
321        }
322
323        Self::new(config)
324    }
325
326    /// Enable caching with the specified cache instance
327    pub fn with_cache(mut self, cache: Arc<FileCache>) -> Self {
328        self.cache = Some(cache);
329        self
330    }
331
332    /// Enable local usage tracking
333    pub fn with_usage_tracking(mut self) -> Self {
334        self.usage_tracker = Some(Arc::new(super::LocalUsageTracker::new()));
335        self
336    }
337
338    /// Get the usage tracker if enabled
339    pub fn usage_tracker(&self) -> Option<&Arc<super::LocalUsageTracker>> {
340        self.usage_tracker.as_ref()
341    }
342
343    /// Fetch usage statistics for the specified period
344    ///
345    /// Note: Anthropic's API does not provide public usage statistics endpoints.
346    /// If local usage tracking is enabled, this returns tracked data.
347    /// Otherwise, it returns mock data explaining the limitation.
348    pub async fn fetch_usage_stats(
349        &self,
350        start_time: DateTime<Utc>,
351        end_time: DateTime<Utc>,
352    ) -> Result<AnthropicUsageStats> {
353        // If local usage tracking is enabled, use that
354        if let Some(tracker) = &self.usage_tracker {
355            tracing::info!(
356                "Returning locally tracked usage statistics for period {} to {}",
357                start_time,
358                end_time
359            );
360            return tracker.get_usage_stats(start_time, end_time).await;
361        }
362
363        tracing::warn!(
364            "Anthropic API does not provide public usage statistics endpoints. \
365             Returning mock data for period {} to {}. \
366             Enable local usage tracking for actual statistics.",
367            start_time,
368            end_time
369        );
370
371        // Return mock usage statistics that indicate the API limitation
372        Ok(self.create_mock_usage_stats(start_time, end_time))
373    }
374
375    /// Fetch current rate limit information
376    ///
377    /// Note: Anthropic's API does not provide public rate limit endpoints.
378    /// This method returns estimated rate limits based on known service tier limits.
379    /// Rate limits are enforced through response headers and HTTP 429 responses.
380    pub async fn fetch_rate_limit_info(&self) -> Result<RateLimitInfo> {
381        // If local usage tracking is enabled, get estimates from there
382        if let Some(tracker) = &self.usage_tracker {
383            return tracker.get_rate_limit_info().await;
384        }
385
386        tracing::warn!(
387            "Anthropic API does not provide public rate limit endpoints. \
388             Returning estimated rate limits based on service tier. \
389             Actual rate limits are enforced through response headers."
390        );
391
392        // Return estimated rate limits based on Anthropic's documented limits
393        // These are conservative estimates and actual limits may vary by service tier
394        Ok(RateLimitInfo {
395            requests_per_minute: 1000, // Conservative estimate for API tier
396            requests_remaining: 1000,  // Cannot determine actual remaining
397            reset_time: Utc::now() + chrono::Duration::seconds(60),
398            tokens_per_minute: Some(50_000), // Conservative estimate
399            tokens_remaining: Some(50_000),  // Cannot determine actual remaining
400        })
401    }
402
403    /// Fetch billing information
404    ///
405    /// Note: Anthropic's API does not provide public billing endpoints.
406    /// This method returns a mock implementation that explains the limitation.
407    /// For actual billing information, use the Anthropic Console at console.anthropic.com.
408    pub async fn fetch_billing_info(
409        &self,
410        start_time: DateTime<Utc>,
411        end_time: DateTime<Utc>,
412    ) -> Result<CostBreakdown> {
413        tracing::warn!(
414            "Anthropic API does not provide public billing endpoints. \
415             Returning mock data for period {} to {}. \
416             For actual billing information, visit the Anthropic Console at console.anthropic.com.",
417            start_time,
418            end_time
419        );
420
421        // Return mock billing information that indicates the API limitation
422        Ok(self.create_mock_cost_breakdown(start_time, end_time))
423    }
424
425    /// Test API connectivity by making a minimal API call
426    ///
427    /// Note: Anthropic's API does not have a dedicated health endpoint.
428    /// This method tests connectivity by making a token counting request.
429    pub async fn health_check(&self) -> Result<bool> {
430        // Create a minimal token counting request to test connectivity
431        let test_payload = serde_json::json!({
432            "model": "claude-3-haiku-20240307",
433            "messages": [{
434                "role": "user",
435                "content": "Hello"
436            }]
437        });
438
439        match self
440            .client
441            .post(format!("{}/v1/messages/count-tokens", self.config.base_url))
442            .json(&test_payload)
443            .send()
444            .await
445        {
446            Ok(response) => {
447                // Accept both 200 (success) and 401 (unauthorized) as "healthy"
448                // 401 means the API is responding but our test key is invalid
449                let status = response.status();
450                Ok(status.is_success() || status == reqwest::StatusCode::UNAUTHORIZED)
451            }
452            Err(_) => Ok(false),
453        }
454    }
455
456    /// Create mock usage statistics that indicate API limitations
457    fn create_mock_usage_stats(
458        &self,
459        start_time: DateTime<Utc>,
460        end_time: DateTime<Utc>,
461    ) -> AnthropicUsageStats {
462        use super::{ApiCallStats, TokenUsage, UsagePeriod};
463        use std::collections::HashMap;
464
465        AnthropicUsageStats {
466            token_usage: TokenUsage {
467                input_tokens: 0,
468                output_tokens: 0,
469                total_tokens: 0,
470                by_model: HashMap::new(),
471            },
472            api_calls: ApiCallStats {
473                total_calls: 0,
474                successful_calls: 0,
475                failed_calls: 0,
476                avg_response_time_ms: 0.0,
477                by_model: HashMap::new(),
478                hourly_breakdown: vec![],
479            },
480            costs: self.create_mock_cost_breakdown(start_time, end_time),
481            model_usage: vec![],
482            period: UsagePeriod {
483                start: start_time,
484                end: end_time,
485                period_type: "mock".to_string(),
486            },
487        }
488    }
489
490    /// Create mock cost breakdown that indicates API limitations
491    fn create_mock_cost_breakdown(
492        &self,
493        _start_time: DateTime<Utc>,
494        _end_time: DateTime<Utc>,
495    ) -> CostBreakdown {
496        use super::TokenCostBreakdown;
497        use std::collections::HashMap;
498
499        CostBreakdown {
500            total_cost_usd: 0.0,
501            by_model: HashMap::new(),
502            by_token_type: TokenCostBreakdown {
503                input_cost_usd: 0.0,
504                output_cost_usd: 0.0,
505            },
506            estimated_monthly_cost_usd: 0.0,
507        }
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use crate::config::{ApiConfig, Config};
515
516    /// Example usage of the Anthropic API client
517    /// This test demonstrates how to use the client (won't run without API key)
518    #[tokio::test]
519    #[ignore = "Integration test - requires ANTHROPIC_API_KEY environment variable"]
520    async fn example_anthropic_api_usage() -> Result<()> {
521        // Create client from environment variables
522        let client = ApiClient::from_env_with_cache().await?;
523
524        // Test API connectivity
525        if let Some(anthropic_client) = client.anthropic() {
526            let is_healthy = anthropic_client.health_check().await?;
527            println!("API health check: {}", is_healthy);
528
529            // Fetch rate limit info
530            match client.fetch_rate_limit_info().await {
531                Ok(rate_limit) => {
532                    println!(
533                        "Rate limit - Remaining: {}, Reset: {:?}",
534                        rate_limit.requests_remaining, rate_limit.reset_time
535                    );
536                }
537                Err(e) => println!("Failed to fetch rate limit info: {}", e),
538            }
539
540            // Fetch usage stats for different periods
541            match client.fetch_daily_usage_stats().await {
542                Ok(daily_stats) => {
543                    println!(
544                        "Daily usage - Total tokens: {}",
545                        daily_stats.token_usage.total_tokens
546                    );
547                    println!(
548                        "Daily usage - API calls: {}",
549                        daily_stats.api_calls.total_calls
550                    );
551                    println!(
552                        "Daily usage - Total cost: ${:.4}",
553                        daily_stats.costs.total_cost_usd
554                    );
555                }
556                Err(e) => println!("Failed to fetch daily usage stats: {}", e),
557            }
558
559            // Fetch comprehensive usage summary
560            match client.get_usage_summary().await {
561                Ok(summary) => {
562                    println!("Usage Summary:");
563                    println!("  Daily tokens: {}", summary.daily.token_usage.total_tokens);
564                    println!(
565                        "  Weekly tokens: {}",
566                        summary.weekly.token_usage.total_tokens
567                    );
568                    println!(
569                        "  Monthly tokens: {}",
570                        summary.monthly.token_usage.total_tokens
571                    );
572                    println!(
573                        "  Rate limit remaining: {}",
574                        summary.rate_limit.requests_remaining
575                    );
576                }
577                Err(e) => println!("Failed to fetch usage summary: {}", e),
578            }
579
580            // Fetch current month billing
581            match client.fetch_current_month_billing().await {
582                Ok(billing) => {
583                    println!("Current month billing: ${:.4}", billing.total_cost_usd);
584                    for (model, cost) in billing.by_model.iter() {
585                        println!("  {}: ${:.4}", model, cost.cost_usd);
586                    }
587                }
588                Err(e) => println!("Failed to fetch billing info: {}", e),
589            }
590        } else {
591            println!("Anthropic API not configured");
592        }
593
594        Ok(())
595    }
596
597    /// Test configuration loading
598    #[tokio::test]
599    async fn test_config_from_env() -> Result<()> {
600        // This test shows how configuration would work
601        let config = Config::from_env()?;
602
603        // The config should have default values if no env vars are set
604        assert_eq!(config.api.timeout_seconds, 30);
605        assert_eq!(config.api.retry_attempts, 3);
606
607        Ok(())
608    }
609
610    /// Test creating client without Anthropic configuration
611    #[tokio::test]
612    async fn test_client_without_anthropic() -> Result<()> {
613        let config = ApiConfig::default();
614        let client = ApiClient::new(config)?;
615
616        // Should succeed but anthropic client should be None
617        assert!(client.anthropic().is_none());
618
619        // Trying to fetch Claude stats should return an error
620        let result = client.fetch_daily_usage_stats().await;
621        assert!(result.is_err());
622
623        Ok(())
624    }
625}