usage_monitor_cli/provider/
devin.rs1use 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
8pub 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 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 assert_eq!(snap.primary_rate_window.unwrap().used, Some(60));
296 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}