Skip to main content

usage_monitor_cli/provider/
windsurf.rs

1//! Windsurf usage provider.
2//!
3//! Ports CodexBar's `GetPlanStatus` call on Windsurf's Connect RPC service
4//! (`exa.seat_management_pb.SeatManagementService`). The request and response
5//! are raw protobuf (`Content-Type: application/proto`, Connect unary), so this
6//! module hand-encodes the request and decodes the response with the shared
7//! [`crate::provider::proto`] reader. Field numbers come from CodexBar, which
8//! reverse-engineered them from Windsurf's bundled protobuf metadata.
9
10use async_trait::async_trait;
11use chrono::{DateTime, TimeZone, Utc};
12
13use crate::error::SpendPanelError;
14use crate::model::{PlanInfo, RateWindow, UsageSnapshot};
15use crate::provider::proto::{self, Reader, WIRE_LEN, WIRE_VARINT};
16use crate::provider::{ProviderContext, ProviderMetadata, UsageProvider};
17
18const PATH: &str = "/_backend/exa.seat_management_pb.SeatManagementService/GetPlanStatus";
19
20/// Decoded `PlanStatus` quota fields.
21#[derive(Debug, Default, Clone, PartialEq)]
22struct PlanStatus {
23    plan_name: Option<String>,
24    daily_remaining_percent: Option<i64>,
25    weekly_remaining_percent: Option<i64>,
26    daily_reset_unix: Option<i64>,
27    weekly_reset_unix: Option<i64>,
28}
29
30/// Windsurf usage provider (Devin-style session auth).
31pub struct WindsurfProvider {
32    metadata: ProviderMetadata,
33    base_url: Option<String>,
34}
35
36impl WindsurfProvider {
37    pub fn new() -> Self {
38        Self {
39            metadata: ProviderMetadata {
40                id: "windsurf",
41                name: "Windsurf",
42                description: "Windsurf daily/weekly quota monitor",
43                auth_methods: &["token", "env"],
44                website: Some("https://windsurf.com"),
45            },
46            base_url: None,
47        }
48    }
49
50    pub fn with_base_url(url: &str) -> Self {
51        let mut p = Self::new();
52        p.base_url = Some(url.to_string());
53        p
54    }
55
56    fn api_base(&self) -> &str {
57        self.base_url.as_deref().unwrap_or("https://windsurf.com")
58    }
59
60    fn clean(raw: &str) -> String {
61        let mut v = raw.trim();
62        if v.len() >= 2
63            && ((v.starts_with('"') && v.ends_with('"'))
64                || (v.starts_with('\'') && v.ends_with('\'')))
65        {
66            v = &v[1..v.len() - 1];
67        }
68        v.trim().to_string()
69    }
70
71    fn config_or_env(ctx: &ProviderContext, keys: &[&str], envs: &[&str]) -> Option<String> {
72        for key in keys {
73            if let Some(v) = ctx.config.get(*key) {
74                let c = Self::clean(v);
75                if !c.is_empty() {
76                    return Some(c);
77                }
78            }
79        }
80        for env in envs {
81            if let Ok(v) = std::env::var(env) {
82                let c = Self::clean(&v);
83                if !c.is_empty() {
84                    return Some(c);
85                }
86            }
87        }
88        None
89    }
90
91    fn resolve_session(ctx: &ProviderContext) -> Result<String, SpendPanelError> {
92        Self::config_or_env(
93            ctx,
94            &["session_token", "token", "api_key"],
95            &["WINDSURF_SESSION_TOKEN"],
96        )
97        .ok_or_else(|| {
98            SpendPanelError::AuthFailed(
99                "windsurf".into(),
100                "no session token in session_token/token config or WINDSURF_SESSION_TOKEN".into(),
101            )
102        })
103    }
104
105    fn build_client(ctx: &ProviderContext) -> Result<reqwest::Client, SpendPanelError> {
106        reqwest::Client::builder()
107            .timeout(std::time::Duration::from_secs(ctx.timeout_secs))
108            .build()
109            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))
110    }
111
112    /// Builds the Connect-unary request body: `{1: auth_token, 2: include_top_up}`.
113    fn encode_request(session_token: &str) -> Vec<u8> {
114        let mut body = Vec::new();
115        proto::encode_string_field(1, session_token, &mut body);
116        proto::encode_varint_field(2, 1, &mut body);
117        body
118    }
119
120    /// Decodes the `GetPlanStatusResponse` → its nested `PlanStatus` (field 1).
121    fn decode_response(data: &[u8]) -> Result<PlanStatus, SpendPanelError> {
122        let mut reader = Reader::new(data);
123        while let Some((field, wire)) = reader.next_key() {
124            if field == 1 && wire == WIRE_LEN {
125                let inner = reader.read_len().ok_or_else(parse_err)?;
126                return Self::decode_plan_status(inner);
127            }
128            reader.skip(wire).ok_or_else(parse_err)?;
129        }
130        // Empty response (no plan status) is still valid — treat as no data.
131        Ok(PlanStatus::default())
132    }
133
134    fn decode_plan_status(data: &[u8]) -> Result<PlanStatus, SpendPanelError> {
135        let mut status = PlanStatus::default();
136        let mut reader = Reader::new(data);
137        while let Some((field, wire)) = reader.next_key() {
138            match (field, wire) {
139                (1, WIRE_LEN) => {
140                    let inner = reader.read_len().ok_or_else(parse_err)?;
141                    status.plan_name = Self::decode_plan_name(inner);
142                }
143                (14, WIRE_VARINT) => {
144                    status.daily_remaining_percent =
145                        Some(reader.read_varint().ok_or_else(parse_err)? as i64);
146                }
147                (15, WIRE_VARINT) => {
148                    status.weekly_remaining_percent =
149                        Some(reader.read_varint().ok_or_else(parse_err)? as i64);
150                }
151                (17, WIRE_VARINT) => {
152                    status.daily_reset_unix =
153                        Some(reader.read_varint().ok_or_else(parse_err)? as i64);
154                }
155                (18, WIRE_VARINT) => {
156                    status.weekly_reset_unix =
157                        Some(reader.read_varint().ok_or_else(parse_err)? as i64);
158                }
159                _ => {
160                    reader.skip(wire).ok_or_else(parse_err)?;
161                }
162            }
163        }
164        Ok(status)
165    }
166
167    /// `PlanInfo { 1: teams_tier (varint), 2: plan_name (string) }`.
168    fn decode_plan_name(data: &[u8]) -> Option<String> {
169        let mut reader = Reader::new(data);
170        while let Some((field, wire)) = reader.next_key() {
171            if field == 2 && wire == WIRE_LEN {
172                let bytes = reader.read_len()?;
173                return std::str::from_utf8(bytes)
174                    .ok()
175                    .map(str::to_string)
176                    .filter(|s| !s.is_empty());
177            }
178            reader.skip(wire)?;
179        }
180        None
181    }
182
183    fn snapshot_from(status: &PlanStatus) -> Result<UsageSnapshot, SpendPanelError> {
184        let to_date = |unix: Option<i64>| -> Option<DateTime<Utc>> {
185            unix.and_then(|s| Utc.timestamp_opt(s, 0).single())
186        };
187
188        let mut snapshot = UsageSnapshot::new("windsurf");
189
190        if let Some(daily) = status.daily_remaining_percent {
191            let used = (100 - daily).clamp(0, 100) as u64;
192            let mut w = RateWindow::new(used, 100, "Daily", 24 * 60);
193            w.resets_at = to_date(status.daily_reset_unix);
194            snapshot.primary_rate_window = Some(w);
195        }
196        if let Some(weekly) = status.weekly_remaining_percent {
197            let used = (100 - weekly).clamp(0, 100) as u64;
198            let mut w = RateWindow::new(used, 100, "Weekly", 7 * 24 * 60);
199            w.resets_at = to_date(status.weekly_reset_unix);
200            snapshot.secondary_rate_window = Some(w);
201        }
202
203        if snapshot.primary_rate_window.is_none() && snapshot.secondary_rate_window.is_none() {
204            return Err(SpendPanelError::ParseError(
205                "windsurf".into(),
206                "no quota data in plan status response".into(),
207            ));
208        }
209
210        if let Some(plan) = &status.plan_name {
211            snapshot.plan = Some(PlanInfo {
212                name: plan.clone(),
213                tier: None,
214                features: Vec::new(),
215                price: None,
216                currency: None,
217                billing_period: None,
218            });
219        }
220        Ok(snapshot)
221    }
222}
223
224fn parse_err() -> SpendPanelError {
225    SpendPanelError::ParseError("windsurf".into(), "malformed protobuf response".into())
226}
227
228impl Default for WindsurfProvider {
229    fn default() -> Self {
230        Self::new()
231    }
232}
233
234#[async_trait]
235impl UsageProvider for WindsurfProvider {
236    fn metadata(&self) -> &ProviderMetadata {
237        &self.metadata
238    }
239
240    fn detect_credentials(&self) -> bool {
241        std::env::var("WINDSURF_SESSION_TOKEN")
242            .map(|v| !v.trim().is_empty())
243            .unwrap_or(false)
244    }
245
246    async fn fetch_usage(&self, ctx: &ProviderContext) -> Result<UsageSnapshot, SpendPanelError> {
247        let session = Self::resolve_session(ctx)?;
248        let client = Self::build_client(ctx)?;
249        let url = format!("{}{}", self.api_base().trim_end_matches('/'), PATH);
250
251        // Optional Devin-session headers; the request body carries the token too.
252        let auth1 = Self::config_or_env(ctx, &["auth1_token"], &["WINDSURF_AUTH1_TOKEN"]);
253        let account_id = Self::config_or_env(ctx, &["account_id"], &["WINDSURF_ACCOUNT_ID"]);
254        let org_id = Self::config_or_env(ctx, &["primary_org_id"], &["WINDSURF_PRIMARY_ORG_ID"]);
255
256        let mut req = client
257            .post(url)
258            .header("Content-Type", "application/proto")
259            .header("Connect-Protocol-Version", "1")
260            .header("Origin", "https://windsurf.com")
261            .header("Referer", "https://windsurf.com/profile")
262            .header("x-auth-token", &session)
263            .header("x-devin-session-token", &session)
264            .body(Self::encode_request(&session));
265        if let Some(v) = &auth1 {
266            req = req.header("x-devin-auth1-token", v);
267        }
268        if let Some(v) = &account_id {
269            req = req.header("x-devin-account-id", v);
270        }
271        if let Some(v) = &org_id {
272            req = req.header("x-devin-primary-org-id", v);
273        }
274
275        let resp = req
276            .send()
277            .await
278            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
279        let status = resp.status();
280        let bytes = resp
281            .bytes()
282            .await
283            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
284        if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
285            return Err(SpendPanelError::AuthFailed(
286                "windsurf".into(),
287                format!("session token rejected (HTTP {})", status.as_u16()),
288            ));
289        }
290        if !status.is_success() {
291            let body = String::from_utf8_lossy(&bytes);
292            return Err(SpendPanelError::ProviderError(
293                "windsurf".into(),
294                format!(
295                    "HTTP {}: {}",
296                    status,
297                    body.chars().take(200).collect::<String>()
298                ),
299            ));
300        }
301        let plan_status = Self::decode_response(&bytes)?;
302        Self::snapshot_from(&plan_status)
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::provider::proto::{encode_key, encode_varint, encode_varint_field};
310    use pretty_assertions::assert_eq;
311    use wiremock::matchers::{header, method, path};
312    use wiremock::{Mock, MockServer, ResponseTemplate};
313
314    /// Wraps `inner` as a length-delimited field of `parent_field`.
315    fn nested(parent_field: u32, inner: &[u8], out: &mut Vec<u8>) {
316        encode_key(parent_field, WIRE_LEN, out);
317        encode_varint(inner.len() as u64, out);
318        out.extend_from_slice(inner);
319    }
320
321    fn sample_response() -> Vec<u8> {
322        // PlanInfo { 1: tier=2, 2: name="Pro" }
323        let mut plan_info = Vec::new();
324        encode_varint_field(1, 2, &mut plan_info);
325        proto::encode_string_field(2, "Pro", &mut plan_info);
326
327        // PlanStatus { 1: plan_info, 14: daily=25, 15: weekly=80, 17: dReset, 18: wReset }
328        let mut plan_status = Vec::new();
329        nested(1, &plan_info, &mut plan_status);
330        encode_varint_field(14, 25, &mut plan_status);
331        encode_varint_field(15, 80, &mut plan_status);
332        encode_varint_field(17, 1_788_000_000, &mut plan_status);
333        encode_varint_field(18, 1_788_500_000, &mut plan_status);
334
335        // GetPlanStatusResponse { 1: plan_status }
336        let mut resp = Vec::new();
337        nested(1, &plan_status, &mut resp);
338        resp
339    }
340
341    #[test]
342    fn test_metadata() {
343        assert_eq!(WindsurfProvider::new().metadata().id, "windsurf");
344    }
345
346    #[test]
347    fn test_encode_request_roundtrips() {
348        let body = WindsurfProvider::encode_request("sess-abc");
349        let mut r = Reader::new(&body);
350        assert_eq!(r.next_key(), Some((1, WIRE_LEN)));
351        assert_eq!(r.read_len(), Some(&b"sess-abc"[..]));
352        assert_eq!(r.next_key(), Some((2, WIRE_VARINT)));
353        assert_eq!(r.read_varint(), Some(1));
354    }
355
356    #[test]
357    fn test_decode_response() {
358        let status = WindsurfProvider::decode_response(&sample_response()).unwrap();
359        assert_eq!(status.plan_name.as_deref(), Some("Pro"));
360        assert_eq!(status.daily_remaining_percent, Some(25));
361        assert_eq!(status.weekly_remaining_percent, Some(80));
362        assert_eq!(status.daily_reset_unix, Some(1_788_000_000));
363    }
364
365    #[test]
366    fn test_snapshot_maps_windows() {
367        let status = WindsurfProvider::decode_response(&sample_response()).unwrap();
368        let snap = WindsurfProvider::snapshot_from(&status).unwrap();
369        // 25% remaining → 75% used; 80% remaining → 20% used.
370        assert_eq!(snap.primary_rate_window.as_ref().unwrap().used, Some(75));
371        assert_eq!(snap.secondary_rate_window.as_ref().unwrap().used, Some(20));
372        assert!(
373            snap.primary_rate_window
374                .as_ref()
375                .unwrap()
376                .resets_at
377                .is_some()
378        );
379        assert_eq!(snap.plan.unwrap().name, "Pro");
380    }
381
382    #[test]
383    fn test_empty_plan_status_is_error() {
384        // Response with a plan status that carries no quota fields.
385        let mut resp = Vec::new();
386        nested(1, &[], &mut resp);
387        let status = WindsurfProvider::decode_response(&resp).unwrap();
388        assert!(matches!(
389            WindsurfProvider::snapshot_from(&status).unwrap_err(),
390            SpendPanelError::ParseError(_, _)
391        ));
392    }
393
394    #[test]
395    fn test_resolve_session_missing() {
396        assert!(matches!(
397            WindsurfProvider::resolve_session(&ProviderContext::new()).unwrap_err(),
398            SpendPanelError::AuthFailed(_, _)
399        ));
400    }
401
402    #[tokio::test]
403    async fn test_fetch_usage_success() {
404        let server = MockServer::start().await;
405        Mock::given(method("POST"))
406            .and(path(PATH))
407            .and(header("x-auth-token", "sess"))
408            .respond_with(
409                ResponseTemplate::new(200).set_body_raw(sample_response(), "application/proto"),
410            )
411            .mount(&server)
412            .await;
413        let provider = WindsurfProvider::with_base_url(&server.uri());
414        let mut ctx = ProviderContext::new();
415        ctx.config.insert("session_token".into(), "sess".into());
416        let snap = provider.fetch_usage(&ctx).await.unwrap();
417        assert_eq!(snap.primary_rate_window.unwrap().used, Some(75));
418    }
419
420    #[tokio::test]
421    async fn test_fetch_usage_401() {
422        let server = MockServer::start().await;
423        Mock::given(method("POST"))
424            .and(path(PATH))
425            .respond_with(ResponseTemplate::new(401))
426            .mount(&server)
427            .await;
428        let provider = WindsurfProvider::with_base_url(&server.uri());
429        let mut ctx = ProviderContext::new();
430        ctx.config.insert("session_token".into(), "bad".into());
431        assert!(matches!(
432            provider.fetch_usage(&ctx).await.unwrap_err(),
433            SpendPanelError::AuthFailed(_, _)
434        ));
435    }
436}