From d640aad000bf3d3659bd51596305756038312a7a Mon Sep 17 00:00:00 2001 From: Itstommy10 Date: Thu, 22 Oct 2026 09:40:23 +0000 Subject: [PATCH] fix(cursor): accept numeric strings in spendLimitUsage Cursor returns some spendLimitUsage fields as strings (e.g. pooledLimit "0" on free accounts), which made the whole usage response fail to decode with "cannot unmarshal string into ... pooledLimit of type int". Decode the integer fields from numbers, numeric strings, empty strings or null. Co-Authored-By: Claude Opus 5.5 --- internal/api/cursor_spend_limit_test.go | 42 +++++++++++++++++ internal/api/cursor_types.go | 60 +++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 internal/api/cursor_spend_limit_test.go diff --git a/internal/api/cursor_spend_limit_test.go b/internal/api/cursor_spend_limit_test.go new file mode 100644 index 00000000..0d325f1e --- /dev/null +++ b/internal/api/cursor_spend_limit_test.go @@ -0,0 +1,42 @@ +package api + +import ( + "encoding/json" + "testing" +) + +func TestCursorSpendLimitUsage_AcceptsStringsAndNumbers(t *testing.T) { + cases := []struct { + name string + in string + want CursorSpendLimitUsage + }{ + {"numbers", `{"totalSpend":150,"pooledLimit":2000,"pooledUsed":150,"pooledRemaining":1850,"limitType":"user"}`, + CursorSpendLimitUsage{TotalSpend: 150, PooledLimit: 2000, PooledUsed: 150, PooledRemaining: 1850, LimitType: "user"}}, + {"numeric strings", `{"pooledLimit":"2000","pooledUsed":"0","individualLimit":"500"}`, + CursorSpendLimitUsage{PooledLimit: 2000, IndividualLimit: 500}}, + {"empty and null", `{"pooledLimit":"","pooledUsed":null,"limitType":null}`, + CursorSpendLimitUsage{}}, + {"float", `{"totalSpend":12.0}`, CursorSpendLimitUsage{TotalSpend: 12}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var got CursorSpendLimitUsage + if err := json.Unmarshal([]byte(tc.in), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got != tc.want { + t.Fatalf("got %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestCursorSpendLimitUsage_NestedInUsageResponse(t *testing.T) { + var wrapper struct { + SpendLimitUsage CursorSpendLimitUsage `json:"spendLimitUsage"` + } + if err := json.Unmarshal([]byte(`{"spendLimitUsage":{"pooledLimit":"0","totalSpend":0}}`), &wrapper); err != nil { + t.Fatalf("unmarshal nested: %v", err) + } +} diff --git a/internal/api/cursor_types.go b/internal/api/cursor_types.go index 0ce6e599..e3733e26 100644 --- a/internal/api/cursor_types.go +++ b/internal/api/cursor_types.go @@ -508,3 +508,63 @@ func buildEnterpriseQuotas(usage *CursorUsageResponse, requestUsage *CursorReque return quotas } + +// UnmarshalJSON accepts the integer fields of spendLimitUsage either as JSON +// numbers or as numeric strings. Cursor returns some of them as strings +// (e.g. "pooledLimit":"0" on free accounts), which broke decoding of the whole +// usage response. Empty or non-numeric strings decode as 0. +func (u *CursorSpendLimitUsage) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + ints := map[string]*int{ + "totalSpend": &u.TotalSpend, + "pooledLimit": &u.PooledLimit, + "pooledUsed": &u.PooledUsed, + "pooledRemaining": &u.PooledRemaining, + "individualLimit": &u.IndividualLimit, + "individualUsed": &u.IndividualUsed, + "individualRemaining": &u.IndividualRemaining, + } + for key, dst := range ints { + v, ok := raw[key] + if !ok { + continue + } + n, err := flexibleInt(v) + if err != nil { + return fmt.Errorf("cursor: spendLimitUsage.%s: %w", key, err) + } + *dst = n + } + if v, ok := raw["limitType"]; ok && string(v) != "null" { + if err := json.Unmarshal(v, &u.LimitType); err != nil { + return fmt.Errorf("cursor: spendLimitUsage.limitType: %w", err) + } + } + return nil +} + +// flexibleInt decodes a JSON number, numeric string, empty string or null as int. +func flexibleInt(v json.RawMessage) (int, error) { + s := strings.TrimSpace(string(v)) + if s == "" || s == "null" { + return 0, nil + } + if s[0] == 0x22 { + var str string + if err := json.Unmarshal(v, &str); err != nil { + return 0, err + } + s = strings.TrimSpace(str) + if s == "" { + return 0, nil + } + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, nil + } + return int(f), nil +}