Skip to main content

usage_monitor_cli/provider/
deepgram.rs

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}