Files
magpie/internal/provider/cursor_usage.go
T
yetone fa9daa6945 docs: every built-in subscription a plugin serves once moved says so in its code, and AGENTS.md says where a fix for it goes (user: 对内置的订阅 provider 在代码层面和文档层面加上注释,防止又有别的 agent 在改内置订阅 provider 的代码而不知道生效的其实是对应的插件代码) — a moved Cursor, Devin, Grok, Command Code plan, Kiro, Zed, Qoder, Factory, Xiaomi MiMo, ZCode or WorkBuddy (provider.Moved; since v0.1.642 also the default for a new sign-in, provider.Adopt) is signed in, listed, requested and metered by its @magpie-community plugin, and none of the built-in's code in internal/provider/<name>*.go, internal/gateway/<name>.go, internal/zed or internal/qoder serves it; a fix made there alone never reaches those users. Each of those files now opens with a PLUGIN-SERVED notice naming the subscription's id, its plugin package, where the plugin lives (magpie-community/plugins, packages/<name>) and the mover whose min to raise so moved users get the fixed plugin (keepMovedCurrent); Qoder's adds that Qoder CN isn't moved and still runs there. AGENTS.md (CLAUDE.md imports it) lists the moved built-ins with their packages and movers, how to tell whether a user's is moved (move.state "plugin", onPlugins), the order a fix goes in (the plugin, publish, raise min, the built-in only for those not moved), the magpie code that does run for a moved one (internal/plugin, plugins.go, plugin_usage.go, pluginsignin.go, migrate*.go, app.js's subOf/pluginSubs/startPluginSignIn), and the parity tests. movers in migrate.go and SUBS in app.js point there too. TestMovedBuiltinsSayTheirPlugin fails for a mover whose code carries no PLUGIN-SERVED notice naming its id and package (it failed while a wrapped package name was split). Comments and docs only, no behaviour changed. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestProvidersAnswerWhileListsComeIn and TestClaudeSignedOut failed once on their timing under load, pass alone; untouched).
2026-10-02 19:37:02 +08:00

179 lines
6.0 KiB
Go

package provider
// PLUGIN-SERVED (see AGENTS.md): Cursor ("cursor") is a deprecated built-in
// subscription served by its plugin, @magpie-community/opencode-cursor-auth,
// once moved onto it (provider.Moved; the default for a new sign-in). A
// moved one's sign-ins, models, requests and usage are all the plugin's,
// never this code's (only the move, in migrate*.go, still reads its
// accounts). A fix here alone doesn't reach those users; fix the plugin
// (github.com/magpie-community/plugins, packages/cursor) and raise the
// mover's min in internal/provider/migrate_side.go.
// 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
}