Files
magpie/internal/provider/cursor_usage.go
T

170 lines
5.4 KiB
Go

package provider
// How much of a Cursor plan's included usage is gone, as the CLI's own
// usage view reads it: the dashboard's current period, split into the
// Cursor Models and Other Models pools.
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"time"
"github.com/yetone/magpie/internal/proc"
)
// cursorBase is Cursor's API; a var so tests can point it elsewhere.
var cursorBase = "https://api2.cursor.sh"
// cursorKeychain reads cursor-agent's token from the macOS Keychain; a var
// so tests stay off the real one.
var cursorKeychain = runtime.GOOS == "darwin"
// cursorToken is the access token cursor-agent signed in with: in the
// Keychain on a Mac, in its auth.json elsewhere.
func cursorToken() (string, error) {
if cursorKeychain {
out, err := proc.Command("security", "find-generic-password", "-s", "cursor-access-token", "-a", "cursor-user", "-w").Output()
if tok := strings.TrimSpace(string(out)); err == nil && tok != "" {
return tok, nil
}
}
var auth struct {
AccessToken string `json:"accessToken"`
}
if path := cursorAuthPath(); path != "" && readJSON(path, &auth) && auth.AccessToken != "" {
return auth.AccessToken, nil
}
return "", errorf("cursor-agent is not signed in")
}
// cursorAuthPath is where cursor-agent keeps its sign-in outside the Keychain.
func cursorAuthPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
switch runtime.GOOS {
case "windows":
dir := os.Getenv("APPDATA")
if dir == "" {
dir = filepath.Join(home, "AppData", "Roaming")
}
return filepath.Join(dir, "Cursor", "auth.json")
case "darwin":
return filepath.Join(home, ".cursor", "auth.json")
}
dir := os.Getenv("XDG_CONFIG_HOME")
if dir == "" {
dir = filepath.Join(home, ".config")
}
return filepath.Join(dir, "cursor", "auth.json")
}
func cursorSubscriptionUsage(ctx context.Context, plan string) SubscriptionQuota {
q := SubscriptionQuota{Provider: "cursor", Name: "Cursor", Icon: "cursor", Plan: plan, Windows: []QuotaWindow{}}
tok, err := cursorToken()
if err == nil {
q.Windows, err = cursorWindows(ctx, tok)
}
if err != nil {
q.Error = err.Error()
}
return q
}
// cursorWindows: the plan's included usage this billing period. An
// enterprise plan reports spend instead, and gets no windows.
func cursorWindows(ctx context.Context, token string) ([]QuotaWindow, error) {
var data struct {
BillingCycleEnd string `json:"billingCycleEnd"` // epoch millis
AutoBucketModels []string `json:"autoBucketModels"`
PlanUsage *struct {
Auto float64 `json:"autoPercentUsed"`
API float64 `json:"apiPercentUsed"`
Total float64 `json:"totalPercentUsed"`
} `json:"planUsage"`
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cursorBase+"/aiserver.v1.DashboardService/GetCurrentPeriodUsage", bytes.NewReader([]byte("{}")))
if err != nil {
return []QuotaWindow{}, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Connect-Protocol-Version", "1")
res, err := http.DefaultClient.Do(req)
if err != nil {
return []QuotaWindow{}, err
}
defer res.Body.Close()
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if res.StatusCode < 200 || res.StatusCode >= 300 {
return []QuotaWindow{}, &accountStatusError{status: res.StatusCode}
}
if err := json.Unmarshal(b, &data); err != nil {
return []QuotaWindow{}, err
}
if data.PlanUsage == nil {
return []QuotaWindow{}, nil
}
var resets *time.Time
if ms, err := strconv.ParseInt(data.BillingCycleEnd, 10, 64); err == nil && ms > 0 {
t := time.UnixMilli(ms)
resets = &t
}
u := data.PlanUsage
inCursorPool := func(model string) bool {
model = cursorPoolBase(model)
if model == "auto" {
model = "default" // the CLI's Auto is default in Cursor's API
}
// the server names a family (grok-4.8); the CLI asks for one at an
// effort or speed (grok-4.8-high-fast)
return slices.ContainsFunc(data.AutoBucketModels, func(m string) bool { return cursorPoolBase(m) == model }) || cursorFirstPartyModel(model)
}
// the two pools fit the line; the total goes in its tooltip
return []QuotaWindow{
{Name: "Cursor Models", Used: u.Auto, ResetsAt: resets, matches: inCursorPool},
{Name: "Other Models", Used: u.API, ResetsAt: resets, matches: func(model string) bool { return !inCursorPool(model) }},
{Name: "Total", Used: u.Total, ResetsAt: resets, Aside: true},
}, nil
}
// cursorPoolBase names a model's family: lower case, without cursor- and
// the effort and speed the CLI adds to it.
func cursorPoolBase(model string) string {
model = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(model)), "cursor-")
for {
b := cursorVariant.ReplaceAllString(model, "")
if b == model {
return model
}
model = b
}
}
// Cursor's autoBucketModels can lag model releases: it still omitted Grok
// 4.6/4.7 when the published Cursor Models pool already included them.
// Keep those documented families alongside the server's exact model list.
// See https://cursor.com/docs/models-and-pricing.
func cursorFirstPartyModel(model string) bool {
model = strings.TrimPrefix(model, "cursor-")
if model == "default" || model == "composer" || strings.HasPrefix(model, "composer-") {
return true
}
for _, base := range []string{"grok-4.5", "grok-4.6", "grok-4.7"} {
if model == base || strings.HasPrefix(model, base+"-") {
return true
}
}
return false
}