Skip to main content

usage_monitor_cli/provider/
elevenlabs.rs

1use async_trait::async_trait;
2use chrono::{DateTime, TimeZone, Utc};
3
4use crate::error::SpendPanelError;
5use crate::model::{NamedRateWindow, PlanInfo, RateWindow, RateWindowStatus, UsageSnapshot};
6use crate::provider::{ProviderContext, ProviderMetadata, UsageProvider};
7
8#[derive(Debug, serde::Deserialize)]
9struct SubscriptionResponse {
10    tier: Option<String>,
11    character_count: u64,
12    character_limit: u64,
13    voice_slots_used: Option<u64>,
14    professional_voice_slots_used: Option<u64>,
15    voice_limit: Option<u64>,
16    professional_voice_limit: Option<u64>,
17    current_overage: Option<Overage>,
18    status: Option<String>,
19    next_character_count_reset_unix: Option<i64>,
20}
21
22#[derive(Debug, serde::Deserialize, Clone, PartialEq)]
23struct Overage {
24    amount: Option<String>,
25    currency: Option<String>,
26}
27
28#[derive(Debug, Clone, PartialEq)]
29struct ElevenLabsUsage {
30    tier: Option<String>,
31    character_count: u64,
32    character_limit: u64,
33    voice_slots_used: Option<u64>,
34    professional_voice_slots_used: Option<u64>,
35    voice_limit: Option<u64>,
36    professional_voice_limit: Option<u64>,
37    current_overage: Option<Overage>,
38    status: Option<String>,
39    resets_at: Option<DateTime<Utc>>,
40}
41
42/// ElevenLabs subscription usage provider.
43pub struct ElevenLabsProvider {
44    metadata: ProviderMetadata,
45    base_url: Option<String>,
46}
47
48impl ElevenLabsProvider {
49    pub fn new() -> Self {
50        Self {
51            metadata: ProviderMetadata {
52                id: "elevenlabs",
53                name: "ElevenLabs",
54                description: "ElevenLabs subscription credit usage monitor",
55                auth_methods: &["api_key", "env"],
56                website: Some("https://elevenlabs.io"),
57            },
58            base_url: None,
59        }
60    }
61
62    pub fn with_base_url(url: &str) -> Self {
63        let mut p = Self::new();
64        p.base_url = Some(url.to_string());
65        p
66    }
67
68    fn clean(raw: &str) -> String {
69        let mut value = raw.trim();
70        if value.len() >= 2
71            && ((value.starts_with('"') && value.ends_with('"'))
72                || (value.starts_with('\'') && value.ends_with('\'')))
73        {
74            value = &value[1..value.len() - 1];
75        }
76        value.trim().to_string()
77    }
78
79    fn resolve_api_key(ctx: &ProviderContext) -> Result<String, SpendPanelError> {
80        for key in ["api_key", "token"] {
81            if let Some(value) = ctx.config.get(key) {
82                let cleaned = Self::clean(value);
83                if !cleaned.is_empty() {
84                    return Ok(cleaned);
85                }
86            }
87        }
88        for env in ["ELEVENLABS_API_KEY", "XI_API_KEY"] {
89            if let Ok(value) = std::env::var(env) {
90                let cleaned = Self::clean(&value);
91                if !cleaned.is_empty() {
92                    return Ok(cleaned);
93                }
94            }
95        }
96        Err(SpendPanelError::AuthFailed(
97            "elevenlabs".into(),
98            "no API key found in config, token, ELEVENLABS_API_KEY, or XI_API_KEY".into(),
99        ))
100    }
101
102    fn api_base(&self, ctx: &ProviderContext) -> String {
103        let configured = ctx
104            .config
105            .get("api_url")
106            .or_else(|| ctx.config.get("base_url"))
107            .map(String::as_str)
108            .filter(|v| !v.is_empty())
109            .map(Self::clean)
110            .or_else(|| {
111                std::env::var("ELEVENLABS_API_URL")
112                    .ok()
113                    .map(|v| Self::clean(&v))
114            })
115            .or_else(|| self.base_url.clone())
116            .unwrap_or_else(|| "https://api.elevenlabs.io".into());
117        let base = if configured.starts_with("http://") || configured.starts_with("https://") {
118            configured
119        } else {
120            format!("https://{}", configured)
121        };
122        base.trim_end_matches('/').to_string()
123    }
124
125    fn subscription_url(base_url: &str) -> String {
126        let base = base_url.trim_end_matches('/');
127        if base.ends_with("/v1") {
128            format!("{}/user/subscription", base)
129        } else {
130            format!("{}/v1/user/subscription", base)
131        }
132    }
133
134    fn build_client(ctx: &ProviderContext) -> Result<reqwest::Client, SpendPanelError> {
135        reqwest::Client::builder()
136            .timeout(std::time::Duration::from_secs(ctx.timeout_secs))
137            .build()
138            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))
139    }
140
141    async fn fetch_subscription(
142        client: &reqwest::Client,
143        url: String,
144        api_key: &str,
145    ) -> Result<SubscriptionResponse, SpendPanelError> {
146        let resp = client
147            .get(url)
148            .header("xi-api-key", api_key)
149            .header("Accept", "application/json")
150            .send()
151            .await
152            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
153        let status = resp.status();
154        let body = resp
155            .text()
156            .await
157            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
158        if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
159            return Err(SpendPanelError::AuthFailed(
160                "elevenlabs".into(),
161                format!("invalid API key (HTTP {})", status.as_u16()),
162            ));
163        }
164        if !status.is_success() {
165            return Err(SpendPanelError::ProviderError(
166                "elevenlabs".into(),
167                format!("HTTP {}", status),
168            ));
169        }
170        serde_json::from_str(&body)
171            .map_err(|e| SpendPanelError::ParseError("elevenlabs".into(), e.to_string()))
172    }
173
174    fn parse_usage(resp: SubscriptionResponse) -> ElevenLabsUsage {
175        ElevenLabsUsage {
176            tier: resp.tier,
177            character_count: resp.character_count,
178            character_limit: resp.character_limit,
179            voice_slots_used: resp.voice_slots_used,
180            professional_voice_slots_used: resp.professional_voice_slots_used,
181            voice_limit: resp.voice_limit,
182            professional_voice_limit: resp.professional_voice_limit,
183            current_overage: resp.current_overage,
184            status: resp.status,
185            resets_at: resp
186                .next_character_count_reset_unix
187                .and_then(|ts| Utc.timestamp_opt(ts, 0).single()),
188        }
189    }
190
191    fn format_int(value: u64) -> String {
192        let s = value.to_string();
193        let mut out = String::new();
194        for (i, ch) in s.chars().rev().enumerate() {
195            if i > 0 && i % 3 == 0 {
196                out.push(',');
197            }
198            out.push(ch);
199        }
200        out.chars().rev().collect()
201    }
202
203    fn display_tier(usage: &ElevenLabsUsage) -> Option<String> {
204        let tier = usage
205            .tier
206            .as_deref()
207            .map(str::trim)
208            .filter(|s| !s.is_empty());
209        match (tier, usage.status.as_deref().filter(|s| !s.is_empty())) {
210            (Some(tier), Some(status)) if !status.eq_ignore_ascii_case("active") => Some(format!(
211                "{} · {}",
212                tier.replace('_', " ")
213                    .split_whitespace()
214                    .map(capitalize)
215                    .collect::<Vec<_>>()
216                    .join(" "),
217                status
218            )),
219            (Some(tier), _) => Some(
220                tier.replace('_', " ")
221                    .split_whitespace()
222                    .map(capitalize)
223                    .collect::<Vec<_>>()
224                    .join(" "),
225            ),
226            (None, Some(status)) => Some(status.to_string()),
227            (None, None) => None,
228        }
229    }
230
231    fn voice_window(id: &str, label: &str, used: u64, limit: u64) -> NamedRateWindow {
232        let ratio = if limit > 0 {
233            used as f64 / limit as f64
234        } else {
235            0.0
236        };
237        NamedRateWindow {
238            id: id.into(),
239            label: label.into(),
240            window: RateWindow {
241                label: format!("{} {} / {}", label, used, limit),
242                window_minutes: 0,
243                usage_ratio: ratio.clamp(0.0, 1.0),
244                limit: Some(limit),
245                used: Some(used),
246                remaining: Some(limit.saturating_sub(used)),
247                resets_at: None,
248                status: RateWindowStatus::from_ratio(ratio),
249            },
250        }
251    }
252
253    fn snapshot_from_usage(usage: ElevenLabsUsage) -> UsageSnapshot {
254        let ratio = if usage.character_limit > 0 {
255            usage.character_count as f64 / usage.character_limit as f64
256        } else {
257            0.0
258        };
259        let mut snapshot = UsageSnapshot::new("elevenlabs");
260        snapshot.primary_rate_window = Some(RateWindow {
261            label: format!(
262                "Credits {} / {}",
263                Self::format_int(usage.character_count),
264                Self::format_int(usage.character_limit)
265            ),
266            window_minutes: 0,
267            usage_ratio: ratio.clamp(0.0, 1.0),
268            limit: Some(usage.character_limit),
269            used: Some(usage.character_count),
270            remaining: Some(usage.character_limit.saturating_sub(usage.character_count)),
271            resets_at: usage.resets_at,
272            status: RateWindowStatus::from_ratio(ratio),
273        });
274        let mut extra = Vec::new();
275        if let (Some(used), Some(limit)) = (usage.voice_slots_used, usage.voice_limit)
276            && limit > 0
277        {
278            extra.push(Self::voice_window(
279                "voice-slots",
280                "Voice slots",
281                used,
282                limit,
283            ));
284        }
285        if let (Some(used), Some(limit)) = (
286            usage.professional_voice_slots_used,
287            usage.professional_voice_limit,
288        ) && limit > 0
289        {
290            extra.push(Self::voice_window(
291                "professional-voices",
292                "Professional voices",
293                used,
294                limit,
295            ));
296        }
297        snapshot.extra_rate_windows = extra;
298        let mut features = Vec::new();
299        if let Some(status) = &usage.status {
300            features.push(format!("status: {}", status));
301        }
302        if let Some(overage) = &usage.current_overage
303            && let Some(amount) = &overage.amount
304        {
305            features.push(format!(
306                "overage: {} {}",
307                amount,
308                overage.currency.as_deref().unwrap_or("")
309            ));
310        }
311        if let Some(name) = Self::display_tier(&usage) {
312            snapshot.plan = Some(PlanInfo {
313                name,
314                tier: usage.tier.clone(),
315                features,
316                price: None,
317                currency: None,
318                billing_period: None,
319            });
320        }
321        snapshot
322    }
323}
324
325fn capitalize(s: &str) -> String {
326    let mut chars = s.chars();
327    match chars.next() {
328        Some(first) => first
329            .to_uppercase()
330            .chain(chars.flat_map(char::to_lowercase))
331            .collect(),
332        None => String::new(),
333    }
334}
335
336impl Default for ElevenLabsProvider {
337    fn default() -> Self {
338        Self::new()
339    }
340}
341
342#[async_trait]
343impl UsageProvider for ElevenLabsProvider {
344    fn metadata(&self) -> &ProviderMetadata {
345        &self.metadata
346    }
347
348    fn detect_credentials(&self) -> bool {
349        ["ELEVENLABS_API_KEY", "XI_API_KEY"]
350            .iter()
351            .any(|env| std::env::var(env).is_ok_and(|v| !Self::clean(&v).is_empty()))
352    }
353
354    async fn fetch_usage(&self, ctx: &ProviderContext) -> Result<UsageSnapshot, SpendPanelError> {
355        let api_key = Self::resolve_api_key(ctx)?;
356        let client = Self::build_client(ctx)?;
357        let url = Self::subscription_url(&self.api_base(ctx));
358        let resp = Self::fetch_subscription(&client, url, &api_key).await?;
359        Ok(Self::snapshot_from_usage(Self::parse_usage(resp)))
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use pretty_assertions::assert_eq;
367    use wiremock::matchers::{header, method, path};
368    use wiremock::{Mock, MockServer, ResponseTemplate};
369
370    const SAMPLE: &str = r#"{
371      "tier":"creator",
372      "character_count":25000,
373      "character_limit":100000,
374      "voice_slots_used":2,
375      "voice_limit":10,
376      "professional_voice_slots_used":1,
377      "professional_voice_limit":2,
378      "current_overage":{"amount":"0","currency":"usd"},
379      "status":"active",
380      "next_character_count_reset_unix":1738356858
381    }"#;
382
383    fn sample_response() -> SubscriptionResponse {
384        serde_json::from_str(SAMPLE).unwrap()
385    }
386
387    #[test]
388    fn test_provider_metadata() {
389        let meta = ElevenLabsProvider::new().metadata().clone();
390        assert_eq!(meta.id, "elevenlabs");
391        assert_eq!(meta.name, "ElevenLabs");
392    }
393
394    #[test]
395    fn test_subscription_url_accepts_versioned_or_root_base_urls() {
396        assert_eq!(
397            ElevenLabsProvider::subscription_url("https://api.elevenlabs.io"),
398            "https://api.elevenlabs.io/v1/user/subscription"
399        );
400        assert_eq!(
401            ElevenLabsProvider::subscription_url("https://api.elevenlabs.io/v1"),
402            "https://api.elevenlabs.io/v1/user/subscription"
403        );
404    }
405
406    #[test]
407    fn test_parse_subscription_response_into_usage_snapshot() {
408        let usage = ElevenLabsProvider::parse_usage(sample_response());
409        assert_eq!(usage.character_count, 25_000);
410        assert_eq!(usage.character_limit, 100_000);
411        let snapshot = ElevenLabsProvider::snapshot_from_usage(usage);
412        let primary = snapshot.primary_rate_window.unwrap();
413        assert_eq!(primary.usage_ratio, 0.25);
414        assert_eq!(primary.used, Some(25_000));
415        assert_eq!(primary.remaining, Some(75_000));
416        assert_eq!(primary.label, "Credits 25,000 / 100,000");
417        assert_eq!(snapshot.extra_rate_windows.len(), 2);
418        assert_eq!(snapshot.plan.unwrap().name, "Creator");
419    }
420
421    #[test]
422    fn test_display_tier_includes_inactive_status() {
423        let mut usage = ElevenLabsProvider::parse_usage(sample_response());
424        usage.tier = Some("professional_plus".into());
425        usage.status = Some("past_due".into());
426        assert_eq!(
427            ElevenLabsProvider::display_tier(&usage).unwrap(),
428            "Professional Plus · past_due"
429        );
430    }
431
432    #[tokio::test]
433    async fn test_fetch_usage_success_sends_xi_api_key_header() {
434        let server = MockServer::start().await;
435        Mock::given(method("GET"))
436            .and(path("/v1/user/subscription"))
437            .and(header("xi-api-key", "xi-test"))
438            .and(header("accept", "application/json"))
439            .respond_with(ResponseTemplate::new(200).set_body_raw(SAMPLE, "application/json"))
440            .mount(&server)
441            .await;
442
443        let provider = ElevenLabsProvider::with_base_url(&server.uri());
444        let snapshot = provider
445            .fetch_usage(&ProviderContext::with_api_key("xi-test"))
446            .await
447            .unwrap();
448        assert_eq!(snapshot.primary_rate_window.unwrap().usage_ratio, 0.25);
449    }
450
451    #[tokio::test]
452    async fn test_fetch_usage_401_is_auth_failed() {
453        let server = MockServer::start().await;
454        Mock::given(method("GET"))
455            .and(path("/v1/user/subscription"))
456            .respond_with(ResponseTemplate::new(401))
457            .mount(&server)
458            .await;
459        let provider = ElevenLabsProvider::with_base_url(&server.uri());
460        let err = provider
461            .fetch_usage(&ProviderContext::with_api_key("bad"))
462            .await
463            .unwrap_err();
464        assert!(matches!(err, SpendPanelError::AuthFailed(_, _)));
465    }
466}