kenn-io--agentsview
f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
303 行
6.9 KiB
Go
303 行
6.9 KiB
Go
package cursorusage
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const defaultBaseURL = "https://api.cursor.com"
|
|
|
|
// Client talks to the Cursor Admin API.
|
|
type Client struct {
|
|
BaseURL string
|
|
APIKey string
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
// TokenUsage mirrors the per-request usage payload.
|
|
type TokenUsage struct {
|
|
InputTokens int `json:"inputTokens"`
|
|
OutputTokens int `json:"outputTokens"`
|
|
CacheWriteTokens int `json:"cacheWriteTokens"`
|
|
CacheReadTokens int `json:"cacheReadTokens"`
|
|
}
|
|
|
|
// UsageEvent is a parsed Cursor usage event.
|
|
type UsageEvent struct {
|
|
Timestamp time.Time
|
|
Model string
|
|
Kind string
|
|
TokenUsage TokenUsage
|
|
ChargedCents float64
|
|
CursorTokenFee float64
|
|
UserID string
|
|
UserEmail string
|
|
IsHeadless bool
|
|
}
|
|
|
|
// Query controls one filtered usage-event page request.
|
|
type Query struct {
|
|
StartDate time.Time
|
|
EndDate time.Time
|
|
Page int
|
|
PageSize int
|
|
Email string
|
|
UserID string
|
|
}
|
|
|
|
// Page is one paginated response from the API.
|
|
type Page struct {
|
|
TotalCount int
|
|
Events []UsageEvent
|
|
}
|
|
|
|
func NewClient(apiKey string) *Client {
|
|
return &Client{
|
|
BaseURL: defaultBaseURL,
|
|
APIKey: apiKey,
|
|
HTTPClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
func NewClientWithBaseURL(baseURL, apiKey string) *Client {
|
|
if strings.TrimSpace(baseURL) == "" {
|
|
baseURL = defaultBaseURL
|
|
}
|
|
c := NewClient(apiKey)
|
|
c.BaseURL = strings.TrimRight(baseURL, "/")
|
|
return c
|
|
}
|
|
|
|
func (c *Client) httpClient() *http.Client {
|
|
if c != nil && c.HTTPClient != nil {
|
|
return c.HTTPClient
|
|
}
|
|
return &http.Client{Timeout: 30 * time.Second}
|
|
}
|
|
|
|
func (c *Client) baseURL() string {
|
|
if c == nil || strings.TrimSpace(c.BaseURL) == "" {
|
|
return defaultBaseURL
|
|
}
|
|
return strings.TrimRight(c.BaseURL, "/")
|
|
}
|
|
|
|
func (c *Client) ListUsageEvents(
|
|
ctx context.Context, q Query,
|
|
) (Page, error) {
|
|
if c == nil {
|
|
return Page{}, fmt.Errorf("nil client")
|
|
}
|
|
if strings.TrimSpace(c.APIKey) == "" {
|
|
return Page{}, fmt.Errorf("missing Cursor admin API key")
|
|
}
|
|
if q.Page <= 0 {
|
|
q.Page = 1
|
|
}
|
|
if q.PageSize <= 0 {
|
|
q.PageSize = 100
|
|
}
|
|
|
|
userID, err := requestUserID(q.UserID)
|
|
if err != nil {
|
|
return Page{}, err
|
|
}
|
|
|
|
body := map[string]any{
|
|
"startDate": timestampMillis(q.StartDate),
|
|
"endDate": timestampMillis(q.EndDate),
|
|
"page": q.Page,
|
|
"pageSize": q.PageSize,
|
|
}
|
|
if strings.TrimSpace(q.Email) != "" {
|
|
body["email"] = strings.TrimSpace(q.Email)
|
|
}
|
|
if strings.TrimSpace(q.UserID) != "" {
|
|
body["userId"] = userID
|
|
}
|
|
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return Page{}, fmt.Errorf("encoding request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodPost,
|
|
c.baseURL()+"/teams/filtered-usage-events",
|
|
bytes.NewReader(data),
|
|
)
|
|
if err != nil {
|
|
return Page{}, fmt.Errorf("creating request: %w", err)
|
|
}
|
|
req.SetBasicAuth(c.APIKey, "")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient().Do(req)
|
|
if err != nil {
|
|
return Page{}, fmt.Errorf("calling filtered usage events: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
return Page{}, fmt.Errorf("cursor admin API returned %s", resp.Status)
|
|
}
|
|
|
|
var decoded usageEventsEnvelope
|
|
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
|
|
return Page{}, fmt.Errorf("decoding filtered usage events: %w", err)
|
|
}
|
|
|
|
rawEvents := decoded.usageEvents()
|
|
events := make([]UsageEvent, 0, len(rawEvents))
|
|
for _, raw := range rawEvents {
|
|
ev, err := parseUsageEvent(raw)
|
|
if err != nil {
|
|
return Page{}, err
|
|
}
|
|
events = append(events, ev)
|
|
}
|
|
|
|
return Page{
|
|
TotalCount: decoded.TotalCount,
|
|
Events: events,
|
|
}, nil
|
|
}
|
|
|
|
func timestampMillis(t time.Time) int64 {
|
|
if t.IsZero() {
|
|
return 0
|
|
}
|
|
return t.UTC().UnixMilli()
|
|
}
|
|
|
|
func requestUserID(raw string) (int64, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return 0, nil
|
|
}
|
|
userID, err := strconv.ParseInt(raw, 10, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid Cursor user ID %q", raw)
|
|
}
|
|
return userID, nil
|
|
}
|
|
|
|
func (c *Client) FetchAllUsageEvents(
|
|
ctx context.Context, q Query,
|
|
) ([]UsageEvent, error) {
|
|
page := q.Page
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
pageSize := q.PageSize
|
|
if pageSize <= 0 {
|
|
pageSize = 100
|
|
}
|
|
|
|
var out []UsageEvent
|
|
for {
|
|
resp, err := c.ListUsageEvents(ctx, Query{
|
|
StartDate: q.StartDate,
|
|
EndDate: q.EndDate,
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
Email: q.Email,
|
|
UserID: q.UserID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, resp.Events...)
|
|
if len(resp.Events) == 0 {
|
|
return out, nil
|
|
}
|
|
if resp.TotalCount > 0 && page*pageSize >= resp.TotalCount {
|
|
return out, nil
|
|
}
|
|
if len(resp.Events) < pageSize {
|
|
return out, nil
|
|
}
|
|
page++
|
|
}
|
|
}
|
|
|
|
type usageEventsEnvelope struct {
|
|
TotalCount int `json:"totalUsageEventsCount"`
|
|
Usage []rawUsageEvent `json:"usageEvents"`
|
|
Display []rawUsageEvent `json:"usageEventsDisplay"`
|
|
}
|
|
|
|
func (e usageEventsEnvelope) usageEvents() []rawUsageEvent {
|
|
if len(e.Usage) > 0 {
|
|
return e.Usage
|
|
}
|
|
return e.Display
|
|
}
|
|
|
|
type rawUsageEvent struct {
|
|
Timestamp string `json:"timestamp"`
|
|
Model string `json:"model"`
|
|
Kind string `json:"kind"`
|
|
TokenUsage tokenUsage `json:"tokenUsage"`
|
|
ChargedCents float64 `json:"chargedCents"`
|
|
CursorTokenFee float64 `json:"cursorTokenFee"`
|
|
UserID string `json:"userId"`
|
|
UserEmail string `json:"userEmail"`
|
|
IsHeadless bool `json:"isHeadless"`
|
|
}
|
|
|
|
type tokenUsage struct {
|
|
InputTokens int `json:"inputTokens"`
|
|
OutputTokens int `json:"outputTokens"`
|
|
CacheWriteTokens int `json:"cacheWriteTokens"`
|
|
CacheReadTokens int `json:"cacheReadTokens"`
|
|
}
|
|
|
|
func parseUsageEvent(raw rawUsageEvent) (UsageEvent, error) {
|
|
t, err := parseCursorTimestamp(raw.Timestamp)
|
|
if err != nil {
|
|
return UsageEvent{}, fmt.Errorf("parsing timestamp: %w", err)
|
|
}
|
|
return UsageEvent{
|
|
Timestamp: t,
|
|
Model: strings.TrimSpace(raw.Model),
|
|
Kind: strings.TrimSpace(raw.Kind),
|
|
TokenUsage: TokenUsage{
|
|
InputTokens: raw.TokenUsage.InputTokens,
|
|
OutputTokens: raw.TokenUsage.OutputTokens,
|
|
CacheWriteTokens: raw.TokenUsage.CacheWriteTokens,
|
|
CacheReadTokens: raw.TokenUsage.CacheReadTokens,
|
|
},
|
|
ChargedCents: raw.ChargedCents,
|
|
CursorTokenFee: raw.CursorTokenFee,
|
|
UserID: strings.TrimSpace(raw.UserID),
|
|
UserEmail: strings.TrimSpace(raw.UserEmail),
|
|
IsHeadless: raw.IsHeadless,
|
|
}, nil
|
|
}
|
|
|
|
func parseCursorTimestamp(raw string) (time.Time, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return time.Time{}, fmt.Errorf("empty timestamp")
|
|
}
|
|
if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
|
return time.Unix(0, ms*int64(time.Millisecond)).UTC(), nil
|
|
}
|
|
if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
|
|
return t.UTC(), nil
|
|
}
|
|
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
|
return t.UTC(), nil
|
|
}
|
|
return time.Time{}, fmt.Errorf("invalid timestamp %q", raw)
|
|
}
|