Skip to main content

usage_monitor_cli/provider/
devin.rs

1use async_trait::async_trait;
2use chrono::{DateTime, Utc};
3
4use crate::error::SpendPanelError;
5use crate::model::{PlanInfo, RateWindow, UsageSnapshot};
6use crate::provider::{ProviderContext, ProviderMetadata, UsageProvider};
7
8/// Devin quota usage provider (app.devin.ai, Bearer token + organization).
9pub struct DevinProvider {
10    metadata: ProviderMetadata,
11    base_url: Option<String>,
12}
13
14impl DevinProvider {
15    pub fn new() -> Self {
16        Self {
17            metadata: ProviderMetadata {
18                id: "devin",
19                name: "Devin",
20                description: "Devin daily/weekly quota monitor",
21                auth_methods: &["token", "api_key", "env"],
22                website: Some("https://devin.ai"),
23            },
24            base_url: None,
25        }
26    }
27
28    pub fn with_base_url(url: &str) -> Self {
29        let mut p = Self::new();
30        p.base_url = Some(url.to_string());
31        p
32    }
33
34    fn api_base(&self) -> &str {
35        self.base_url.as_deref().unwrap_or("https://app.devin.ai")
36    }
37
38    fn clean(raw: &str) -> String {
39        let mut v = raw.trim();
40        if v.len() >= 2
41            && ((v.starts_with('"') && v.ends_with('"'))
42                || (v.starts_with('\'') && v.ends_with('\'')))
43        {
44            v = &v[1..v.len() - 1];
45        }
46        // Allow pasting a full "Bearer <token>" or "Authorization: Bearer <token>".
47        let mut s = v.trim().to_string();
48        if let Some(rest) = s.strip_prefix("Authorization:") {
49            s = rest.trim().to_string();
50        }
51        if let Some(rest) = s.strip_prefix("Bearer ") {
52            s = rest.trim().to_string();
53        }
54        s
55    }
56
57    fn resolve_token(ctx: &ProviderContext) -> Result<String, SpendPanelError> {
58        for key in ["token", "api_key"] {
59            if let Some(v) = ctx.config.get(key) {
60                let c = Self::clean(v);
61                if !c.is_empty() {
62                    return Ok(c);
63                }
64            }
65        }
66        for env in ["DEVIN_TOKEN", "DEVIN_API_TOKEN"] {
67            if let Ok(v) = std::env::var(env) {
68                let c = Self::clean(&v);
69                if !c.is_empty() {
70                    return Ok(c);
71                }
72            }
73        }
74        Err(SpendPanelError::AuthFailed(
75            "devin".into(),
76            "no Bearer token in token/api_key config, DEVIN_TOKEN, or DEVIN_API_TOKEN".into(),
77        ))
78    }
79
80    fn resolve_org(ctx: &ProviderContext) -> Result<String, SpendPanelError> {
81        for key in ["organization", "org", "organization_id"] {
82            if let Some(v) = ctx
83                .config
84                .get(key)
85                .map(|s| s.trim())
86                .filter(|s| !s.is_empty())
87            {
88                let trimmed = v.trim_matches('/');
89                return Ok(trimmed.to_string());
90            }
91        }
92        if let Ok(v) = std::env::var("DEVIN_ORG") {
93            let t = v.trim().trim_matches('/');
94            if !t.is_empty() {
95                return Ok(t.to_string());
96            }
97        }
98        Err(SpendPanelError::ProviderError(
99            "devin".into(),
100            "no organization configured; set `devin set organization <slug>` or DEVIN_ORG".into(),
101        ))
102    }
103
104    fn build_client(ctx: &ProviderContext) -> Result<reqwest::Client, SpendPanelError> {
105        reqwest::Client::builder()
106            .timeout(std::time::Duration::from_secs(ctx.timeout_secs))
107            .build()
108            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))
109    }
110
111    fn window(
112        percent: Option<f64>,
113        reset: Option<&serde_json::Value>,
114        minutes: u32,
115        label: &str,
116    ) -> Option<RateWindow> {
117        let p = percent?;
118        let used = if p <= 1.0 { p * 100.0 } else { p };
119        let mut w = RateWindow::new(
120            used.clamp(0.0, 100.0).round() as u64,
121            100,
122            label.to_string(),
123            minutes,
124        );
125        w.resets_at = reset.and_then(parse_reset);
126        Some(w)
127    }
128
129    fn parse(body: &str) -> Result<UsageSnapshot, SpendPanelError> {
130        let json: serde_json::Value = serde_json::from_str(body)
131            .map_err(|e| SpendPanelError::ParseError("devin".into(), e.to_string()))?;
132
133        let daily = Self::window(
134            json.get("daily_percentage").and_then(num),
135            json.get("daily_reset_at"),
136            24 * 60,
137            "Daily",
138        );
139        let weekly = Self::window(
140            json.get("weekly_percentage").and_then(num),
141            json.get("weekly_reset_at"),
142            7 * 24 * 60,
143            "Weekly",
144        );
145
146        if daily.is_none() && weekly.is_none() {
147            return Err(SpendPanelError::ParseError(
148                "devin".into(),
149                "no daily/weekly quota in response".into(),
150            ));
151        }
152
153        let mut snapshot = UsageSnapshot::new("devin");
154        snapshot.primary_rate_window = daily;
155        snapshot.secondary_rate_window = weekly;
156        if let Some(plan) = json
157            .get("plan_name")
158            .or_else(|| json.get("plan"))
159            .and_then(|v| v.as_str())
160            .filter(|s| !s.is_empty())
161        {
162            snapshot.plan = Some(PlanInfo {
163                name: plan.to_string(),
164                tier: None,
165                features: Vec::new(),
166                price: None,
167                currency: None,
168                billing_period: None,
169            });
170        }
171        Ok(snapshot)
172    }
173}
174
175fn num(v: &serde_json::Value) -> Option<f64> {
176    v.as_f64()
177        .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
178}
179
180fn parse_reset(v: &serde_json::Value) -> Option<DateTime<Utc>> {
181    if let Some(s) = v.as_str() {
182        if let Ok(secs) = s.parse::<i64>() {
183            let secs = if secs > 1_000_000_000_000 {
184                secs / 1000
185            } else {
186                secs
187            };
188            return chrono::TimeZone::timestamp_opt(&Utc, secs, 0).single();
189        }
190        return DateTime::parse_from_rfc3339(s)
191            .ok()
192            .map(|d| d.with_timezone(&Utc));
193    }
194    if let Some(secs) = v.as_i64() {
195        let secs = if secs > 1_000_000_000_000 {
196            secs / 1000
197        } else {
198            secs
199        };
200        return chrono::TimeZone::timestamp_opt(&Utc, secs, 0).single();
201    }
202    None
203}
204
205impl Default for DevinProvider {
206    fn default() -> Self {
207        Self::new()
208    }
209}
210
211#[async_trait]
212impl UsageProvider for DevinProvider {
213    fn metadata(&self) -> &ProviderMetadata {
214        &self.metadata
215    }
216
217    fn detect_credentials(&self) -> bool {
218        ["DEVIN_TOKEN", "DEVIN_API_TOKEN"].iter().any(|e| {
219            std::env::var(e)
220                .map(|v| !v.trim().is_empty())
221                .unwrap_or(false)
222        })
223    }
224
225    async fn fetch_usage(&self, ctx: &ProviderContext) -> Result<UsageSnapshot, SpendPanelError> {
226        let token = Self::resolve_token(ctx)?;
227        let org = Self::resolve_org(ctx)?;
228        let client = Self::build_client(ctx)?;
229        let url = format!(
230            "{}/api/{}/billing/quota/usage",
231            self.api_base().trim_end_matches('/'),
232            org
233        );
234        let resp = client
235            .get(url)
236            .header("Authorization", format!("Bearer {}", token))
237            .header("Accept", "application/json")
238            .send()
239            .await
240            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
241        let status = resp.status();
242        let body = resp
243            .text()
244            .await
245            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
246        if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
247            return Err(SpendPanelError::AuthFailed(
248                "devin".into(),
249                format!("invalid Bearer token (HTTP {})", status.as_u16()),
250            ));
251        }
252        if !status.is_success() {
253            return Err(SpendPanelError::ProviderError(
254                "devin".into(),
255                format!("HTTP {}: {}", status, body),
256            ));
257        }
258        Self::parse(&body)
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use pretty_assertions::assert_eq;
266    use wiremock::matchers::{method, path};
267    use wiremock::{Mock, MockServer, ResponseTemplate};
268
269    const SAMPLE: &str =
270        r#"{"daily_percentage": 0.6, "weekly_percentage": 35, "plan_name": "Team"}"#;
271
272    #[test]
273    fn test_metadata() {
274        assert_eq!(DevinProvider::new().metadata().id, "devin");
275    }
276
277    #[test]
278    fn test_clean_strips_bearer() {
279        assert_eq!(DevinProvider::clean("Bearer abc"), "abc");
280        assert_eq!(DevinProvider::clean("Authorization: Bearer xyz"), "xyz");
281    }
282
283    #[test]
284    fn test_org_required() {
285        assert!(matches!(
286            DevinProvider::resolve_org(&ProviderContext::new()).unwrap_err(),
287            SpendPanelError::ProviderError(_, _)
288        ));
289    }
290
291    #[test]
292    fn test_parse_fraction_and_percent() {
293        let snap = DevinProvider::parse(SAMPLE).unwrap();
294        // 0.6 fraction → 60% used
295        assert_eq!(snap.primary_rate_window.unwrap().used, Some(60));
296        // 35 already a percent
297        assert_eq!(snap.secondary_rate_window.unwrap().used, Some(35));
298        assert_eq!(snap.plan.unwrap().name, "Team");
299    }
300
301    #[test]
302    fn test_weekly_only() {
303        let snap = DevinProvider::parse(r#"{"weekly_percentage": 0.5}"#).unwrap();
304        assert!(snap.primary_rate_window.is_none());
305        assert_eq!(snap.secondary_rate_window.unwrap().used, Some(50));
306    }
307
308    #[test]
309    fn test_no_windows_is_error() {
310        assert!(matches!(
311            DevinProvider::parse(r#"{"plan_name":"X"}"#).unwrap_err(),
312            SpendPanelError::ParseError(_, _)
313        ));
314    }
315
316    #[tokio::test]
317    async fn test_fetch_success() {
318        let server = MockServer::start().await;
319        Mock::given(method("GET"))
320            .and(path("/api/myorg/billing/quota/usage"))
321            .respond_with(ResponseTemplate::new(200).set_body_raw(SAMPLE, "application/json"))
322            .mount(&server)
323            .await;
324        let provider = DevinProvider::with_base_url(&server.uri());
325        let mut ctx = ProviderContext::new();
326        ctx.config.insert("token".into(), "t".into());
327        ctx.config.insert("organization".into(), "myorg".into());
328        let snap = provider.fetch_usage(&ctx).await.unwrap();
329        assert_eq!(snap.primary_rate_window.unwrap().used, Some(60));
330    }
331
332    #[tokio::test]
333    async fn test_fetch_401() {
334        let server = MockServer::start().await;
335        Mock::given(method("GET"))
336            .and(path("/api/myorg/billing/quota/usage"))
337            .respond_with(ResponseTemplate::new(403))
338            .mount(&server)
339            .await;
340        let provider = DevinProvider::with_base_url(&server.uri());
341        let mut ctx = ProviderContext::new();
342        ctx.config.insert("token".into(), "bad".into());
343        ctx.config.insert("organization".into(), "myorg".into());
344        assert!(matches!(
345            provider.fetch_usage(&ctx).await.unwrap_err(),
346            SpendPanelError::AuthFailed(_, _)
347        ));
348    }
349}