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
42pub 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}