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