use crate::auth::AuthResolver;
use crate::providers::{ProviderKind, ProviderQuota, ProviderResult, ProviderStatus, QuotaWindow};
use crate::Result;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use reqwest::Client;
use serde::Deserialize;
pub struct CursorProvider {
http: Client,
auth: Box<dyn AuthResolver>,
}
impl CursorProvider {
pub fn new(auth: Box<dyn AuthResolver>) -> Self {
Self {
http: Client::new(),
auth,
}
}
async fn fetch_quota(&self, session_token: &str) -> Result<ProviderResult> {
let make_req = |endpoint: &str, body: serde_json::Value| {
let token = session_token.to_string();
self.http
.post(endpoint)
.header("Content-Type", "application/json")
.header("Origin", "https://cursor.com")
.header("Referer", "https://cursor.com/dashboard/spending")
.header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")
.header("Cookie", format!("WorkosCursorSessionToken={}", token))
.body(serde_json::to_string(&body).unwrap())
.send()
};
let plan_resp = make_req(
"https://cursor.com/api/dashboard/get-plan-info",
serde_json::json!({}),
)
.await?;
let usage_resp = make_req(
"https://cursor.com/api/dashboard/get-current-period-usage",
serde_json::json!({}),
)
.await?;
let plan_status = plan_resp.status();
let usage_status = usage_resp.status();
let plan_body: serde_json::Value = plan_resp.json().await?;
let usage_body: serde_json::Value = usage_resp.json().await?;
if plan_status.as_u16() == 401 || usage_status.as_u16() == 401 {
return Ok(ProviderResult {
kind: ProviderKind::Cursor,
status: ProviderStatus::AuthRequired,
fetched_at: Utc::now(),
raw_response: Some(serde_json::json!({
"plan": plan_body,
"usage": usage_body,
})),
auth_source: None,
cached_at: None,
});
}
if !plan_status.is_success() || !usage_status.is_success() {
let msg = plan_body
.get("error")
.or_else(|| usage_body.get("error"))
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
return Ok(ProviderResult {
kind: ProviderKind::Cursor,
status: ProviderStatus::Unavailable {
info: crate::providers::UnavailableInfo {
reason: msg.to_string(),
console_url: Some("https://cursor.com/dashboard/spending".into()),
},
},
fetched_at: Utc::now(),
raw_response: Some(serde_json::json!({
"plan": plan_body,
"usage": usage_body,
})),
auth_source: None,
cached_at: None,
});
}
let quota = parse_quota(&plan_body, &usage_body)?;
Ok(ProviderResult {
kind: ProviderKind::Cursor,
status: ProviderStatus::Available { quota },
fetched_at: Utc::now(),
raw_response: Some(serde_json::json!({
"plan": plan_body,
"usage": usage_body,
})),
auth_source: None,
cached_at: None,
})
}
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct PlanInfoResponse {
#[serde(default, rename = "planInfo")]
plan_info: Option<PlanInfo>,
#[serde(default, rename = "nextUpgrade")]
next_upgrade: Option<NextUpgrade>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct PlanInfo {
#[serde(default, rename = "planName")]
plan_name: Option<String>,
#[serde(default, rename = "price")]
price_str: Option<String>,
#[serde(default, rename = "billingCycleEnd")]
billing_cycle_end: Option<String>,
#[serde(default, rename = "includedAmountCents")]
included_amount_cents: Option<i64>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct NextUpgrade {
#[serde(default, rename = "name")]
name: Option<String>,
#[serde(default, rename = "price")]
price_str: Option<String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct UsageResponse {
#[serde(default, rename = "billingCycleEnd")]
billing_cycle_end: Option<String>,
#[serde(default, rename = "planUsage")]
plan_usage: Option<PlanUsage>,
#[serde(default, rename = "spendLimitUsage")]
spend_limit_usage: Option<SpendLimitUsage>,
}
#[derive(Debug, Deserialize)]
struct PlanUsage {
#[serde(default, rename = "totalSpend")]
total_spend: Option<f64>,
#[serde(default, rename = "includedSpend")]
included_spend: Option<f64>,
#[serde(default, rename = "bonusSpend")]
bonus_spend: Option<f64>,
#[serde(default, rename = "apiPercentUsed")]
api_percent_used: Option<f64>,
#[serde(default, rename = "autoPercentUsed")]
auto_percent_used: Option<f64>,
}
#[derive(Debug, Deserialize)]
struct SpendLimitUsage {
#[serde(default, rename = "individualLimit")]
individual_limit: Option<i64>,
#[serde(default, rename = "individualRemaining")]
individual_remaining: Option<i64>,
}
fn parse_quota(plan_body: &serde_json::Value, usage_body: &serde_json::Value) -> Result<ProviderQuota> {
let plan: PlanInfoResponse = serde_json::from_value(plan_body.clone())
.map_err(|e| crate::Error::Provider(format!("parse plan error: {}", e)))?;
let usage: UsageResponse = serde_json::from_value(usage_body.clone())
.map_err(|e| crate::Error::Provider(format!("parse usage error: {}", e)))?;
let mut windows = Vec::new();
if let Some(plan_usage) = &usage.plan_usage {
let included_spend = plan_usage.included_spend.unwrap_or(0.0) as i64;
let total_spend = plan_usage.total_spend.unwrap_or(0.0) as i64;
let bonus_spend = plan_usage.bonus_spend.unwrap_or(0.0) as i64;
let used = total_spend - bonus_spend; let limit = included_spend;
let remaining = limit.saturating_sub(used);
let reset_at = usage.billing_cycle_end.and_then(|ts| {
ts.parse::<i64>().ok().map(|ms| {
DateTime::from_timestamp(ms / 1000, 0)
.unwrap_or_else(Utc::now)
})
});
windows.push(QuotaWindow {
window_type: "billing_cycle".to_string(),
used,
limit,
remaining,
reset_at,
period_seconds: None,
});
if bonus_spend > 0 {
windows.push(QuotaWindow {
window_type: "bonus".to_string(),
used: 0,
limit: bonus_spend,
remaining: bonus_spend,
reset_at: None,
period_seconds: None,
});
}
}
if let Some(spend_limit) = &usage.spend_limit_usage {
if let (Some(limit), Some(remaining)) = (spend_limit.individual_limit, spend_limit.individual_remaining) {
let used = limit.saturating_sub(remaining);
windows.push(QuotaWindow {
window_type: "spend_limit".to_string(),
used,
limit,
remaining,
reset_at: None,
period_seconds: None,
});
}
}
if let Some(plan_usage) = &usage.plan_usage {
if let Some(api_pct) = plan_usage.api_percent_used {
windows.push(QuotaWindow {
window_type: "api_usage_pct".to_string(),
used: (api_pct * 100.0) as i64,
limit: 10000, remaining: ((100.0 - api_pct) * 100.0) as i64,
reset_at: None,
period_seconds: None,
});
}
if let Some(auto_pct) = plan_usage.auto_percent_used {
windows.push(QuotaWindow {
window_type: "auto_usage_pct".to_string(),
used: (auto_pct * 100.0) as i64,
limit: 10000,
remaining: ((100.0 - auto_pct) * 100.0) as i64,
reset_at: None,
period_seconds: None,
});
}
}
let plan_name = plan.plan_info
.as_ref()
.and_then(|p| p.plan_name.clone())
.unwrap_or_else(|| "Cursor".to_string());
Ok(ProviderQuota {
plan_name,
windows,
unlimited: false,
})
}
#[async_trait]
impl crate::providers::Provider for CursorProvider {
fn kind(&self) -> ProviderKind {
ProviderKind::Cursor
}
async fn fetch(&self) -> Result<ProviderResult> {
let auth = self.auth.resolve().await?;
let session_token = auth.credential.unwrap_cookie()?.to_string();
match self.fetch_quota(&session_token).await {
Ok(r) => Ok(r),
Err(e) => Ok(ProviderResult {
kind: ProviderKind::Cursor,
status: ProviderStatus::NetworkError {
message: e.to_string(),
},
fetched_at: Utc::now(),
raw_response: None,
auth_source: None,
cached_at: None,
}),
}
}
fn auth_resolver(&self) -> &dyn AuthResolver {
&*self.auth
}
}