1use async_trait::async_trait;
2
3use crate::error::SpendPanelError;
4use crate::model::{NamedRateWindow, PlanInfo, RateWindow, RateWindowStatus, UsageSnapshot};
5use crate::provider::{ProviderContext, ProviderMetadata, UsageProvider};
6
7#[derive(Debug, serde::Deserialize)]
8struct ProjectsResponse {
9 projects: Vec<Project>,
10}
11
12#[derive(Debug, serde::Deserialize, Clone, PartialEq)]
13struct Project {
14 project_id: String,
15 name: Option<String>,
16}
17
18#[derive(Debug, serde::Deserialize)]
19struct UsageResponse {
20 start: Option<String>,
21 end: Option<String>,
22 results: Vec<UsageResult>,
23}
24
25#[derive(Debug, serde::Deserialize)]
26struct UsageResult {
27 hours: Option<f64>,
28 total_hours: Option<f64>,
29 agent_hours: Option<f64>,
30 tokens_in: Option<u64>,
31 tokens_out: Option<u64>,
32 tts_characters: Option<u64>,
33 requests: Option<u64>,
34}
35
36#[derive(Debug, Clone, PartialEq)]
37struct DeepgramUsage {
38 project_id: String,
39 project_name: Option<String>,
40 project_count: usize,
41 start: Option<String>,
42 end: Option<String>,
43 hours: f64,
44 total_hours: f64,
45 agent_hours: f64,
46 tokens_in: u64,
47 tokens_out: u64,
48 tts_characters: u64,
49 requests: u64,
50}
51
52pub struct DeepgramProvider {
53 metadata: ProviderMetadata,
54 base_url: Option<String>,
55}
56
57impl DeepgramProvider {
58 pub fn new() -> Self {
59 Self {
60 metadata: ProviderMetadata {
61 id: "deepgram",
62 name: "Deepgram",
63 description: "Deepgram usage breakdown monitor",
64 auth_methods: &["api_key", "project_id", "env"],
65 website: Some("https://deepgram.com"),
66 },
67 base_url: None,
68 }
69 }
70
71 pub fn with_base_url(url: &str) -> Self {
72 let mut p = Self::new();
73 p.base_url = Some(url.to_string());
74 p
75 }
76
77 fn clean(raw: &str) -> String {
78 let mut value = raw.trim();
79 if value.len() >= 2
80 && ((value.starts_with('"') && value.ends_with('"'))
81 || (value.starts_with('\'') && value.ends_with('\'')))
82 {
83 value = &value[1..value.len() - 1];
84 }
85 value.trim().to_string()
86 }
87
88 fn resolve_api_key(ctx: &ProviderContext) -> Result<String, SpendPanelError> {
89 for key in ["api_key", "token"] {
90 if let Some(value) = ctx.config.get(key) {
91 let cleaned = Self::clean(value);
92 if !cleaned.is_empty() {
93 return Ok(cleaned);
94 }
95 }
96 }
97 if let Ok(value) = std::env::var("DEEPGRAM_API_KEY") {
98 let cleaned = Self::clean(&value);
99 if !cleaned.is_empty() {
100 return Ok(cleaned);
101 }
102 }
103 Err(SpendPanelError::AuthFailed(
104 "deepgram".into(),
105 "no API key found in config, token, or DEEPGRAM_API_KEY".into(),
106 ))
107 }
108
109 fn resolve_project_id(ctx: &ProviderContext) -> Option<String> {
110 ctx.config
111 .get("project_id")
112 .map(|v| Self::clean(v))
113 .filter(|v| !v.is_empty())
114 .or_else(|| {
115 std::env::var("DEEPGRAM_PROJECT_ID")
116 .ok()
117 .map(|v| Self::clean(&v))
118 })
119 .filter(|v| !v.is_empty())
120 }
121
122 fn api_base(&self, ctx: &ProviderContext) -> String {
123 let configured = ctx
124 .config
125 .get("api_url")
126 .or_else(|| ctx.config.get("base_url"))
127 .map(String::as_str)
128 .filter(|v| !v.is_empty())
129 .map(Self::clean)
130 .or_else(|| {
131 std::env::var("DEEPGRAM_API_URL")
132 .ok()
133 .map(|v| Self::clean(&v))
134 })
135 .or_else(|| self.base_url.clone())
136 .unwrap_or_else(|| "https://api.deepgram.com/v1".into());
137 let base = if configured.starts_with("http://") || configured.starts_with("https://") {
138 configured
139 } else {
140 format!("https://{}", configured)
141 };
142 base.trim_end_matches('/').to_string()
143 }
144
145 fn build_client(ctx: &ProviderContext) -> Result<reqwest::Client, SpendPanelError> {
146 reqwest::Client::builder()
147 .timeout(std::time::Duration::from_secs(ctx.timeout_secs))
148 .build()
149 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))
150 }
151
152 async fn get_json<T: serde::de::DeserializeOwned>(
153 client: &reqwest::Client,
154 url: String,
155 api_key: &str,
156 ) -> Result<T, SpendPanelError> {
157 let resp = client
158 .get(url)
159 .header("Authorization", format!("Token {}", api_key))
160 .header("Accept", "application/json")
161 .send()
162 .await
163 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
164 let status = resp.status();
165 let body = resp
166 .text()
167 .await
168 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
169 match status.as_u16() {
170 200 => serde_json::from_str(&body)
171 .map_err(|e| SpendPanelError::ParseError("deepgram".into(), e.to_string())),
172 401 => Err(SpendPanelError::AuthFailed(
173 "deepgram".into(),
174 "API key is invalid or expired".into(),
175 )),
176 403 => Err(SpendPanelError::AuthFailed(
177 "deepgram".into(),
178 "API key does not have access to project or Management API".into(),
179 )),
180 _ => Err(SpendPanelError::ProviderError(
181 "deepgram".into(),
182 format!("HTTP {}: {}", status, body),
183 )),
184 }
185 }
186
187 async fn list_projects(
188 client: &reqwest::Client,
189 base_url: &str,
190 api_key: &str,
191 ) -> Result<Vec<Project>, SpendPanelError> {
192 let response: ProjectsResponse =
193 Self::get_json(client, format!("{}/projects", base_url), api_key).await?;
194 Ok(response.projects)
195 }
196
197 async fn fetch_project_usage(
198 client: &reqwest::Client,
199 base_url: &str,
200 api_key: &str,
201 project: Project,
202 ctx: &ProviderContext,
203 ) -> Result<DeepgramUsage, SpendPanelError> {
204 let mut url = reqwest::Url::parse(&format!(
205 "{}/projects/{}/usage/breakdown",
206 base_url, project.project_id
207 ))
208 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
209 for key in ["start", "end"] {
210 if let Some(value) = ctx
211 .config
212 .get(key)
213 .map(|v| Self::clean(v))
214 .filter(|v| !v.is_empty())
215 {
216 url.query_pairs_mut().append_pair(key, &value);
217 }
218 }
219 let response: UsageResponse = Self::get_json(client, url.to_string(), api_key).await?;
220 Ok(Self::parse_usage(project, response))
221 }
222
223 fn parse_usage(project: Project, response: UsageResponse) -> DeepgramUsage {
224 DeepgramUsage {
225 project_id: project.project_id,
226 project_name: project.name,
227 project_count: 1,
228 start: response.start,
229 end: response.end,
230 hours: response
231 .results
232 .iter()
233 .map(|r| r.hours.unwrap_or(0.0))
234 .sum(),
235 total_hours: response
236 .results
237 .iter()
238 .map(|r| r.total_hours.unwrap_or(0.0))
239 .sum(),
240 agent_hours: response
241 .results
242 .iter()
243 .map(|r| r.agent_hours.unwrap_or(0.0))
244 .sum(),
245 tokens_in: response
246 .results
247 .iter()
248 .map(|r| r.tokens_in.unwrap_or(0))
249 .sum(),
250 tokens_out: response
251 .results
252 .iter()
253 .map(|r| r.tokens_out.unwrap_or(0))
254 .sum(),
255 tts_characters: response
256 .results
257 .iter()
258 .map(|r| r.tts_characters.unwrap_or(0))
259 .sum(),
260 requests: response
261 .results
262 .iter()
263 .map(|r| r.requests.unwrap_or(0))
264 .sum(),
265 }
266 }
267
268 fn aggregate(usages: Vec<DeepgramUsage>) -> Result<DeepgramUsage, SpendPanelError> {
269 let Some(first) = usages.first() else {
270 return Err(SpendPanelError::ProviderError(
271 "deepgram".into(),
272 "no projects returned".into(),
273 ));
274 };
275 if usages.len() == 1 {
276 return Ok(first.clone());
277 }
278 Ok(DeepgramUsage {
279 project_id: "all".into(),
280 project_name: None,
281 project_count: usages.len(),
282 start: usages.iter().filter_map(|u| u.start.clone()).min(),
283 end: usages.iter().filter_map(|u| u.end.clone()).max(),
284 hours: usages.iter().map(|u| u.hours).sum(),
285 total_hours: usages.iter().map(|u| u.total_hours).sum(),
286 agent_hours: usages.iter().map(|u| u.agent_hours).sum(),
287 tokens_in: usages.iter().map(|u| u.tokens_in).sum(),
288 tokens_out: usages.iter().map(|u| u.tokens_out).sum(),
289 tts_characters: usages.iter().map(|u| u.tts_characters).sum(),
290 requests: usages.iter().map(|u| u.requests).sum(),
291 })
292 }
293
294 fn format_int(value: u64) -> String {
295 let s = value.to_string();
296 let mut out = String::new();
297 for (i, ch) in s.chars().rev().enumerate() {
298 if i > 0 && i % 3 == 0 {
299 out.push(',');
300 }
301 out.push(ch);
302 }
303 out.chars().rev().collect()
304 }
305
306 fn format_decimal(value: f64) -> String {
307 if value.fract() == 0.0 {
308 format!("{:.0}", value)
309 } else {
310 format!("{:.1}", value)
311 }
312 }
313
314 fn zero_window(label: impl Into<String>) -> RateWindow {
315 RateWindow {
316 label: label.into(),
317 window_minutes: 0,
318 usage_ratio: 0.0,
319 limit: None,
320 used: None,
321 remaining: None,
322 resets_at: None,
323 status: RateWindowStatus::Normal,
324 }
325 }
326
327 fn identity_label(usage: &DeepgramUsage) -> String {
328 if usage.project_count > 1 {
329 format!("{} projects", usage.project_count)
330 } else if let Some(name) = usage
331 .project_name
332 .as_deref()
333 .filter(|s| !s.trim().is_empty())
334 {
335 format!("Project: {}", name.trim())
336 } else {
337 format!("Project: {}", usage.project_id)
338 }
339 }
340
341 fn snapshot_from_usage(usage: DeepgramUsage) -> UsageSnapshot {
342 let mut snapshot = UsageSnapshot::new("deepgram");
343 snapshot.primary_rate_window = Some(Self::zero_window(format!(
344 "Requests {}",
345 Self::format_int(usage.requests)
346 )));
347 snapshot.secondary_rate_window = Some(Self::zero_window(format!(
348 "Audio {} h · Billable {} h",
349 Self::format_decimal(usage.hours),
350 Self::format_decimal(usage.total_hours)
351 )));
352 let token_total = usage.tokens_in + usage.tokens_out;
353 snapshot.tertiary_rate_window = Some(Self::zero_window(format!(
354 "Models {} tokens · {} TTS chars",
355 Self::format_int(token_total),
356 Self::format_int(usage.tts_characters)
357 )));
358 if usage.agent_hours > 0.0 {
359 snapshot.extra_rate_windows.push(NamedRateWindow {
360 id: "agent-hours".into(),
361 label: "Agent hours".into(),
362 window: Self::zero_window(format!(
363 "Agent {} h",
364 Self::format_decimal(usage.agent_hours)
365 )),
366 });
367 }
368 let mut features = vec![Self::identity_label(&usage)];
369 if let (Some(start), Some(end)) = (&usage.start, &usage.end) {
370 features.push(format!("period: {} to {}", start, end));
371 }
372 snapshot.plan = Some(PlanInfo {
373 name: "Deepgram API".into(),
374 tier: None,
375 features,
376 price: None,
377 currency: None,
378 billing_period: None,
379 });
380 snapshot
381 }
382}
383
384impl Default for DeepgramProvider {
385 fn default() -> Self {
386 Self::new()
387 }
388}
389
390#[async_trait]
391impl UsageProvider for DeepgramProvider {
392 fn metadata(&self) -> &ProviderMetadata {
393 &self.metadata
394 }
395
396 fn detect_credentials(&self) -> bool {
397 std::env::var("DEEPGRAM_API_KEY").is_ok_and(|v| !Self::clean(&v).is_empty())
398 }
399
400 async fn fetch_usage(&self, ctx: &ProviderContext) -> Result<UsageSnapshot, SpendPanelError> {
401 let api_key = Self::resolve_api_key(ctx)?;
402 let client = Self::build_client(ctx)?;
403 let base_url = self.api_base(ctx);
404 let usages = if let Some(project_id) = Self::resolve_project_id(ctx) {
405 vec![
406 Self::fetch_project_usage(
407 &client,
408 &base_url,
409 &api_key,
410 Project {
411 project_id,
412 name: None,
413 },
414 ctx,
415 )
416 .await?,
417 ]
418 } else {
419 let projects = Self::list_projects(&client, &base_url, &api_key).await?;
420 if projects.is_empty() {
421 return Err(SpendPanelError::ProviderError(
422 "deepgram".into(),
423 "no projects returned".into(),
424 ));
425 }
426 let mut usages = Vec::with_capacity(projects.len());
427 for project in projects {
428 usages.push(
429 Self::fetch_project_usage(&client, &base_url, &api_key, project, ctx).await?,
430 );
431 }
432 usages
433 };
434 Ok(Self::snapshot_from_usage(Self::aggregate(usages)?))
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use pretty_assertions::assert_eq;
442 use wiremock::matchers::{header, method, path, query_param};
443 use wiremock::{Mock, MockServer, ResponseTemplate};
444
445 const USAGE: &str = r#"{
446 "start":"2025-01-16",
447 "end":"2025-01-23",
448 "results":[
449 {"hours":1619.7242069444444,"total_hours":1621.7395791666668,"agent_hours":41.33564388888889,"tokens_in":1200,"tokens_out":340,"tts_characters":9158866,"requests":373381},
450 {"hours":2.25,"total_hours":3.5,"requests":19}
451 ]
452 }"#;
453
454 fn usage_response() -> UsageResponse {
455 serde_json::from_str(USAGE).unwrap()
456 }
457
458 #[test]
459 fn test_provider_metadata() {
460 let meta = DeepgramProvider::new().metadata().clone();
461 assert_eq!(meta.id, "deepgram");
462 assert_eq!(meta.name, "Deepgram");
463 }
464
465 #[test]
466 fn test_parse_usage_breakdown_response() {
467 let usage = DeepgramProvider::parse_usage(
468 Project {
469 project_id: "project-123".into(),
470 name: None,
471 },
472 usage_response(),
473 );
474 assert_eq!(usage.requests, 373_400);
475 assert_eq!(usage.tokens_in, 1200);
476 assert_eq!(usage.tokens_out, 340);
477 assert_eq!(usage.tts_characters, 9_158_866);
478 let snapshot = DeepgramProvider::snapshot_from_usage(usage);
479 assert_eq!(
480 snapshot.primary_rate_window.unwrap().label,
481 "Requests 373,400"
482 );
483 assert!(
484 snapshot
485 .secondary_rate_window
486 .unwrap()
487 .label
488 .contains("Audio 1622.0 h")
489 );
490 assert!(
491 snapshot
492 .tertiary_rate_window
493 .unwrap()
494 .label
495 .contains("1,540 tokens")
496 );
497 assert_eq!(snapshot.extra_rate_windows.len(), 1);
498 }
499
500 #[test]
501 fn test_aggregate_projects() {
502 let a = DeepgramUsage {
503 project_id: "a".into(),
504 project_name: Some("A".into()),
505 project_count: 1,
506 start: Some("2025-01-16".into()),
507 end: Some("2025-01-23".into()),
508 hours: 1.0,
509 total_hours: 2.0,
510 agent_hours: 0.0,
511 tokens_in: 1,
512 tokens_out: 2,
513 tts_characters: 3,
514 requests: 4,
515 };
516 let b = DeepgramUsage {
517 project_id: "b".into(),
518 project_name: Some("B".into()),
519 project_count: 1,
520 start: Some("2025-01-17".into()),
521 end: Some("2025-01-24".into()),
522 hours: 4.0,
523 total_hours: 5.0,
524 agent_hours: 0.0,
525 tokens_in: 10,
526 tokens_out: 20,
527 tts_characters: 30,
528 requests: 6,
529 };
530 let usage = DeepgramProvider::aggregate(vec![a, b]).unwrap();
531 assert_eq!(usage.project_id, "all");
532 assert_eq!(usage.project_count, 2);
533 assert_eq!(usage.requests, 10);
534 assert_eq!(usage.hours, 5.0);
535 assert_eq!(usage.start.as_deref(), Some("2025-01-16"));
536 assert_eq!(usage.end.as_deref(), Some("2025-01-24"));
537 }
538
539 #[tokio::test]
540 async fn test_fetch_usage_calls_breakdown_endpoint_with_token_auth() {
541 let server = MockServer::start().await;
542 Mock::given(method("GET"))
543 .and(path("/v1/projects/project-123/usage/breakdown"))
544 .and(query_param("start", "2025-01-16"))
545 .and(query_param("end", "2025-01-23"))
546 .and(header("authorization", "Token dg-test"))
547 .and(header("accept", "application/json"))
548 .respond_with(ResponseTemplate::new(200).set_body_raw(USAGE, "application/json"))
549 .mount(&server)
550 .await;
551 let provider = DeepgramProvider::with_base_url(&format!("{}/v1", server.uri()));
552 let mut ctx = ProviderContext::with_api_key("dg-test");
553 ctx.config.insert("project_id".into(), "project-123".into());
554 ctx.config.insert("start".into(), "2025-01-16".into());
555 ctx.config.insert("end".into(), "2025-01-23".into());
556 let snapshot = provider.fetch_usage(&ctx).await.unwrap();
557 assert_eq!(
558 snapshot.primary_rate_window.unwrap().label,
559 "Requests 373,400"
560 );
561 }
562
563 #[tokio::test]
564 async fn test_fetch_usage_discovers_projects_when_project_id_omitted() {
565 let server = MockServer::start().await;
566 Mock::given(method("GET"))
567 .and(path("/v1/projects"))
568 .respond_with(ResponseTemplate::new(200).set_body_raw(
569 r#"{"projects":[{"project_id":"project-a","name":"Alpha"},{"project_id":"project-b","name":"Beta"}]}"#,
570 "application/json",
571 ))
572 .mount(&server)
573 .await;
574 Mock::given(method("GET"))
575 .and(path("/v1/projects/project-a/usage/breakdown"))
576 .respond_with(ResponseTemplate::new(200).set_body_raw(
577 r#"{"start":"2025-01-16","end":"2025-01-23","results":[{"hours":1,"total_hours":2,"requests":3}]}"#,
578 "application/json",
579 ))
580 .mount(&server)
581 .await;
582 Mock::given(method("GET"))
583 .and(path("/v1/projects/project-b/usage/breakdown"))
584 .respond_with(ResponseTemplate::new(200).set_body_raw(
585 r#"{"start":"2025-01-17","end":"2025-01-24","results":[{"hours":4,"total_hours":5,"requests":6}]}"#,
586 "application/json",
587 ))
588 .mount(&server)
589 .await;
590 let provider = DeepgramProvider::with_base_url(&format!("{}/v1", server.uri()));
591 let snapshot = provider
592 .fetch_usage(&ProviderContext::with_api_key("dg-test"))
593 .await
594 .unwrap();
595 assert_eq!(snapshot.primary_rate_window.unwrap().label, "Requests 9");
596 assert!(
597 snapshot
598 .plan
599 .unwrap()
600 .features
601 .contains(&"2 projects".into())
602 );
603 }
604
605 #[tokio::test]
606 async fn test_fetch_usage_401_is_auth_failed() {
607 let server = MockServer::start().await;
608 Mock::given(method("GET"))
609 .and(path("/v1/projects/project-123/usage/breakdown"))
610 .respond_with(ResponseTemplate::new(401))
611 .mount(&server)
612 .await;
613 let provider = DeepgramProvider::with_base_url(&format!("{}/v1", server.uri()));
614 let mut ctx = ProviderContext::with_api_key("bad");
615 ctx.config.insert("project_id".into(), "project-123".into());
616 let err = provider.fetch_usage(&ctx).await.unwrap_err();
617 assert!(matches!(err, SpendPanelError::AuthFailed(_, _)));
618 }
619}