256 lines
6.6 KiB
Go
256 lines
6.6 KiB
Go
|
|
package cmd
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
type ModelUsage struct {
|
||
|
|
NumRequests int `json:"numRequests"`
|
||
|
|
NumRequestsTotal int `json:"numRequestsTotal"`
|
||
|
|
NumTokens int `json:"numTokens"`
|
||
|
|
MaxTokenUsage *int `json:"maxTokenUsage"`
|
||
|
|
MaxRequestUsage *int `json:"maxRequestUsage"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type UsageData struct {
|
||
|
|
StartOfMonth string `json:"startOfMonth"`
|
||
|
|
Models map[string]ModelUsage `json:"-"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type StripeProfile struct {
|
||
|
|
MembershipType string `json:"membershipType"`
|
||
|
|
SubscriptionStatus string `json:"subscriptionStatus"`
|
||
|
|
DaysRemainingOnTrial *int `json:"daysRemainingOnTrial"`
|
||
|
|
IsTeamMember bool `json:"isTeamMember"`
|
||
|
|
IsYearlyPlan bool `json:"isYearlyPlan"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func DecodeJWTPayload(token string) map[string]interface{} {
|
||
|
|
parts := strings.Split(token, ".")
|
||
|
|
if len(parts) < 2 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
padded := strings.ReplaceAll(parts[1], "-", "+")
|
||
|
|
padded = strings.ReplaceAll(padded, "_", "/")
|
||
|
|
data, err := base64.StdEncoding.DecodeString(padded + strings.Repeat("=", (4-len(padded)%4)%4))
|
||
|
|
if err != nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
var result map[string]interface{}
|
||
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
func TokenSub(token string) string {
|
||
|
|
payload := DecodeJWTPayload(token)
|
||
|
|
if payload == nil {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
if sub, ok := payload["sub"].(string); ok {
|
||
|
|
return sub
|
||
|
|
}
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
|
||
|
|
func apiGet(path, token string) (map[string]interface{}, error) {
|
||
|
|
client := &http.Client{Timeout: 8 * time.Second}
|
||
|
|
req, err := http.NewRequest("GET", "https://api2.cursor.sh"+path, nil)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
||
|
|
|
||
|
|
resp, err := client.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
data, err := io.ReadAll(resp.Body)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
var result map[string]interface{}
|
||
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
||
|
|
return nil, nil
|
||
|
|
}
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func FetchAccountUsage(token string) (*UsageData, error) {
|
||
|
|
raw, err := apiGet("/auth/usage", token)
|
||
|
|
if err != nil || raw == nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
startOfMonth, _ := raw["startOfMonth"].(string)
|
||
|
|
usage := &UsageData{
|
||
|
|
StartOfMonth: startOfMonth,
|
||
|
|
Models: make(map[string]ModelUsage),
|
||
|
|
}
|
||
|
|
|
||
|
|
for k, v := range raw {
|
||
|
|
if k == "startOfMonth" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
data, err := json.Marshal(v)
|
||
|
|
if err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
var mu ModelUsage
|
||
|
|
if err := json.Unmarshal(data, &mu); err == nil {
|
||
|
|
usage.Models[k] = mu
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return usage, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func FetchStripeProfile(token string) (*StripeProfile, error) {
|
||
|
|
raw, err := apiGet("/auth/full_stripe_profile", token)
|
||
|
|
if err != nil || raw == nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
profile := &StripeProfile{
|
||
|
|
MembershipType: fmt.Sprintf("%v", raw["membershipType"]),
|
||
|
|
SubscriptionStatus: fmt.Sprintf("%v", raw["subscriptionStatus"]),
|
||
|
|
IsTeamMember: raw["isTeamMember"] == true,
|
||
|
|
IsYearlyPlan: raw["isYearlyPlan"] == true,
|
||
|
|
}
|
||
|
|
if d, ok := raw["daysRemainingOnTrial"].(float64); ok {
|
||
|
|
di := int(d)
|
||
|
|
profile.DaysRemainingOnTrial = &di
|
||
|
|
}
|
||
|
|
return profile, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func DescribePlan(profile *StripeProfile) string {
|
||
|
|
if profile == nil {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
switch profile.MembershipType {
|
||
|
|
case "free_trial":
|
||
|
|
days := 0
|
||
|
|
if profile.DaysRemainingOnTrial != nil {
|
||
|
|
days = *profile.DaysRemainingOnTrial
|
||
|
|
}
|
||
|
|
return fmt.Sprintf("Pro Trial (%dd left) — unlimited fast requests", days)
|
||
|
|
case "pro":
|
||
|
|
return "Pro — extended limits"
|
||
|
|
case "pro_plus":
|
||
|
|
return "Pro+ — extended limits"
|
||
|
|
case "ultra":
|
||
|
|
return "Ultra — extended limits"
|
||
|
|
case "free", "hobby":
|
||
|
|
return "Hobby (free) — limited agent requests"
|
||
|
|
default:
|
||
|
|
return fmt.Sprintf("%s · %s", profile.MembershipType, profile.SubscriptionStatus)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
var modelLabels = map[string]string{
|
||
|
|
"gpt-4": "Fast Premium Requests",
|
||
|
|
"claude-sonnet-4-6": "Claude Sonnet 4.6",
|
||
|
|
"claude-sonnet-4-5-20250929-v1": "Claude Sonnet 4.5",
|
||
|
|
"claude-sonnet-4-20250514-v1": "Claude Sonnet 4",
|
||
|
|
"claude-opus-4-6-v1": "Claude Opus 4.6",
|
||
|
|
"claude-opus-4-5-20251101-v1": "Claude Opus 4.5",
|
||
|
|
"claude-opus-4-1-20250805-v1": "Claude Opus 4.1",
|
||
|
|
"claude-opus-4-20250514-v1": "Claude Opus 4",
|
||
|
|
"claude-haiku-4-5-20251001-v1": "Claude Haiku 4.5",
|
||
|
|
"claude-3-5-haiku-20241022-v1": "Claude 3.5 Haiku",
|
||
|
|
"gpt-5": "GPT-5",
|
||
|
|
"gpt-4o": "GPT-4o",
|
||
|
|
"o1": "o1",
|
||
|
|
"o3-mini": "o3-mini",
|
||
|
|
"cursor-small": "Cursor Small (free)",
|
||
|
|
}
|
||
|
|
|
||
|
|
func modelLabel(key string) string {
|
||
|
|
if label, ok := modelLabels[key]; ok {
|
||
|
|
return label
|
||
|
|
}
|
||
|
|
return key
|
||
|
|
}
|
||
|
|
|
||
|
|
func FormatUsageSummary(usage *UsageData) []string {
|
||
|
|
if usage == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
var lines []string
|
||
|
|
|
||
|
|
start := "?"
|
||
|
|
if usage.StartOfMonth != "" {
|
||
|
|
if t, err := time.Parse(time.RFC3339, usage.StartOfMonth); err == nil {
|
||
|
|
start = t.Format("2006-01-02")
|
||
|
|
} else {
|
||
|
|
start = usage.StartOfMonth
|
||
|
|
}
|
||
|
|
}
|
||
|
|
lines = append(lines, fmt.Sprintf(" Billing period from %s", start))
|
||
|
|
|
||
|
|
if len(usage.Models) == 0 {
|
||
|
|
lines = append(lines, " No requests this billing period")
|
||
|
|
return lines
|
||
|
|
}
|
||
|
|
|
||
|
|
type entry struct {
|
||
|
|
key string
|
||
|
|
usage ModelUsage
|
||
|
|
}
|
||
|
|
var entries []entry
|
||
|
|
for k, v := range usage.Models {
|
||
|
|
entries = append(entries, entry{k, v})
|
||
|
|
}
|
||
|
|
|
||
|
|
// Sort: entries with limits first, then by usage descending
|
||
|
|
for i := 1; i < len(entries); i++ {
|
||
|
|
for j := i; j > 0; j-- {
|
||
|
|
a, b := entries[j-1], entries[j]
|
||
|
|
aHasLimit := a.usage.MaxRequestUsage != nil
|
||
|
|
bHasLimit := b.usage.MaxRequestUsage != nil
|
||
|
|
if !aHasLimit && bHasLimit {
|
||
|
|
entries[j-1], entries[j] = entries[j], entries[j-1]
|
||
|
|
} else if aHasLimit == bHasLimit && a.usage.NumRequests < b.usage.NumRequests {
|
||
|
|
entries[j-1], entries[j] = entries[j], entries[j-1]
|
||
|
|
} else {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, e := range entries {
|
||
|
|
used := e.usage.NumRequests
|
||
|
|
max := e.usage.MaxRequestUsage
|
||
|
|
label := modelLabel(e.key)
|
||
|
|
if max != nil && *max > 0 {
|
||
|
|
pct := int(float64(used) / float64(*max) * 100)
|
||
|
|
bar := makeBar(used, *max, 12)
|
||
|
|
lines = append(lines, fmt.Sprintf(" %s: %d/%d (%d%%) [%s]", label, used, *max, pct, bar))
|
||
|
|
} else if used > 0 {
|
||
|
|
lines = append(lines, fmt.Sprintf(" %s: %d requests", label, used))
|
||
|
|
} else {
|
||
|
|
lines = append(lines, fmt.Sprintf(" %s: 0 requests (unlimited)", label))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return lines
|
||
|
|
}
|
||
|
|
|
||
|
|
func makeBar(used, max, width int) string {
|
||
|
|
fill := int(float64(used) / float64(max) * float64(width))
|
||
|
|
if fill > width {
|
||
|
|
fill = width
|
||
|
|
}
|
||
|
|
return strings.Repeat("█", fill) + strings.Repeat("░", width-fill)
|
||
|
|
}
|