usage_monitor_cli/provider/
windsurf.rs1use 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#[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
30pub 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 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 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 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 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 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 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 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 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 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 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 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}