diff --git a/internal/provider/claude/parser.go b/internal/provider/claude/parser.go index 531a574..fe99cc9 100644 --- a/internal/provider/claude/parser.go +++ b/internal/provider/claude/parser.go @@ -1,9 +1,11 @@ package claude import ( + "bytes" "encoding/json" "fmt" "math" + "sort" "strings" "github.com/jacobcxdev/cq/internal/quota" @@ -17,6 +19,29 @@ type profile struct { Plan string } +type usageWindow struct { + Utilization float64 `json:"utilization"` + ResetsAt string `json:"resets_at"` +} + +type usageLimit struct { + Kind string `json:"kind"` + Group string `json:"group"` + Percent float64 `json:"percent"` + ResetsAt string `json:"resets_at"` + Scope *scope `json:"scope"` +} + +type scope struct { + Model *scopeValue `json:"model"` + Surface *scopeValue `json:"surface"` +} + +type scopeValue struct { + ID *string `json:"id"` + DisplayName string `json:"display_name"` +} + // parseProfile decodes a Claude profile API JSON body and normalises the plan // name (e.g. "claude_max" → "max"). func parseProfile(body []byte) profile { @@ -46,28 +71,7 @@ func parseProfile(body []byte) profile { // parseUsage decodes a Claude usage API JSON body and returns a quota.Result. func parseUsage(body []byte, plan, rateLimitTier, email, uuid string) quota.Result { - var usage struct { - FiveHour *struct { - Utilization float64 `json:"utilization"` - ResetsAt string `json:"resets_at"` - } `json:"five_hour"` - SevenDay *struct { - Utilization float64 `json:"utilization"` - ResetsAt string `json:"resets_at"` - } `json:"seven_day"` - SevenDaySonnet *struct { - Utilization float64 `json:"utilization"` - ResetsAt string `json:"resets_at"` - } `json:"seven_day_sonnet"` - SevenDayOpus *struct { - Utilization float64 `json:"utilization"` - ResetsAt string `json:"resets_at"` - } `json:"seven_day_opus"` - SevenDayOmelette *struct { - Utilization float64 `json:"utilization"` - ResetsAt string `json:"resets_at"` - } `json:"seven_day_omelette"` - } + var usage map[string]json.RawMessage if err := json.Unmarshal(body, &usage); err != nil { return quota.ErrorResult("parse_error", fmt.Sprintf("parse: %v", err), 0) } @@ -77,22 +81,47 @@ func parseUsage(body []byte, plan, rateLimitTier, email, uuid string) quota.Resu pct = max(0, min(100, pct)) return quota.Window{RemainingPct: pct, ResetAtUnix: quota.ParseResetTime(resetsAt)} } + toWindowFromPercent := func(percent float64, resetsAt string) quota.Window { + pct := int(math.Round(100 - percent)) + pct = max(0, min(100, pct)) + return quota.Window{RemainingPct: pct, ResetAtUnix: quota.ParseResetTime(resetsAt)} + } windows := make(map[quota.WindowName]quota.Window) - if usage.FiveHour != nil { - windows[quota.Window5Hour] = toWindow(usage.FiveHour.Utilization, usage.FiveHour.ResetsAt) - } - if usage.SevenDay != nil { - windows[quota.Window7Day] = toWindow(usage.SevenDay.Utilization, usage.SevenDay.ResetsAt) - } - if usage.SevenDaySonnet != nil { - windows[quota.WindowName("7d:sonnet")] = toWindow(usage.SevenDaySonnet.Utilization, usage.SevenDaySonnet.ResetsAt) + keys := make([]string, 0, len(usage)) + for key := range usage { + keys = append(keys, key) } - if usage.SevenDayOpus != nil { - windows[quota.WindowName("7d:opus")] = toWindow(usage.SevenDayOpus.Utilization, usage.SevenDayOpus.ResetsAt) + sort.Strings(keys) + for _, key := range keys { + winName, ok := claudeUsageWindowName(key) + if !ok { + continue + } + if bytes.Equal(bytes.TrimSpace(usage[key]), []byte("null")) { + continue + } + var win usageWindow + if err := json.Unmarshal(usage[key], &win); err != nil { + return quota.ErrorResult("parse_error", fmt.Sprintf("parse: %v", err), 0) + } + if _, exists := windows[winName]; exists { + continue + } + windows[winName] = toWindow(win.Utilization, win.ResetsAt) } - if usage.SevenDayOmelette != nil { - windows[quota.WindowName("7d:design")] = toWindow(usage.SevenDayOmelette.Utilization, usage.SevenDayOmelette.ResetsAt) + if raw, ok := usage["limits"]; ok && !bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + var limits []usageLimit + if err := json.Unmarshal(raw, &limits); err != nil { + return quota.ErrorResult("parse_error", fmt.Sprintf("parse: %v", err), 0) + } + for _, limit := range limits { + winName, ok := claudeLimitWindowName(limit) + if !ok { + continue + } + windows[winName] = toWindowFromPercent(limit.Percent, limit.ResetsAt) + } } return quota.Result{ @@ -105,6 +134,99 @@ func parseUsage(body []byte, plan, rateLimitTier, email, uuid string) quota.Resu } } +func claudeLimitWindowName(limit usageLimit) (quota.WindowName, bool) { + base, ok := claudeLimitBaseWindow(limit.Group, limit.Kind) + if !ok { + return "", false + } + bucket := claudeScopeBucket(limit.Scope) + if bucket == "" { + return base, true + } + return quota.WindowName(string(base) + ":" + bucket), true +} + +func claudeLimitBaseWindow(group, kind string) (quota.WindowName, bool) { + switch group { + case "session": + return quota.Window5Hour, true + case "weekly": + return quota.Window7Day, true + } + switch kind { + case "session": + return quota.Window5Hour, true + case "weekly_all", "weekly_scoped": + return quota.Window7Day, true + default: + return "", false + } +} + +func claudeScopeBucket(scope *scope) string { + if scope == nil { + return "" + } + for _, value := range []*scopeValue{scope.Model, scope.Surface} { + if value == nil { + continue + } + if value.ID != nil && strings.TrimSpace(*value.ID) != "" { + return normaliseClaudeUsageBucket(*value.ID) + } + if strings.TrimSpace(value.DisplayName) != "" { + return normaliseClaudeUsageBucket(value.DisplayName) + } + } + return "" +} + +func claudeUsageWindowName(key string) (quota.WindowName, bool) { + switch key { + case "five_hour": + return quota.Window5Hour, true + case "seven_day": + return quota.Window7Day, true + } + + for _, scoped := range []struct { + prefix string + base quota.WindowName + }{ + {prefix: "five_hour_", base: quota.Window5Hour}, + {prefix: "seven_day_", base: quota.Window7Day}, + } { + bucket, ok := strings.CutPrefix(key, scoped.prefix) + if !ok || bucket == "" { + continue + } + bucket = claudeUsageBucket(bucket) + if bucket == "" { + continue + } + return quota.WindowName(string(scoped.base) + ":" + bucket), true + } + + return "", false +} + +func claudeUsageBucket(bucket string) string { + bucket = normaliseClaudeUsageBucket(bucket) + switch bucket { + case "omelette": + return "design" + default: + return bucket + } +} + +func normaliseClaudeUsageBucket(bucket string) string { + bucket = strings.TrimSpace(strings.ToLower(bucket)) + bucket = strings.ReplaceAll(bucket, " ", "-") + bucket = strings.ReplaceAll(bucket, "_", "-") + return bucket +} + // dedup removes duplicate accounts and filters out errored results when usable // results exist for the same account. If multiple results share the same // account identity and some are usable while others are errors, the errors are diff --git a/internal/provider/claude/parser_test.go b/internal/provider/claude/parser_test.go index 465df7d..bd52674 100644 --- a/internal/provider/claude/parser_test.go +++ b/internal/provider/claude/parser_test.go @@ -260,7 +260,7 @@ func TestParseUsageModelSpecificSevenDayWindows(t *testing.T) { body := []byte(`{ "five_hour": {"utilization": 30.0, "resets_at": "2026-03-20T12:00:00Z"}, "seven_day": {"utilization": 10.0, "resets_at": "2026-03-25T00:00:00Z"}, - "seven_day_sonnet": {"utilization": 25.0, "resets_at": "2026-03-24T00:00:00Z"}, + "seven_day_fable": {"utilization": 25.0, "resets_at": "2026-03-24T00:00:00Z"}, "seven_day_opus": {"utilization": 40.0, "resets_at": "2026-03-23T00:00:00Z"}, "seven_day_omelette": {"utilization": 55.0, "resets_at": "2026-03-22T00:00:00Z"} }`) @@ -270,7 +270,7 @@ func TestParseUsageModelSpecificSevenDayWindows(t *testing.T) { name string want int }{ - {name: "7d:sonnet", want: 75}, + {name: "7d:fable", want: 75}, {name: "7d:opus", want: 60}, {name: "7d:design", want: 45}, } @@ -289,6 +289,98 @@ func TestParseUsageModelSpecificSevenDayWindows(t *testing.T) { } } +func TestParseUsageIncludesFutureBackendFedModelSpecificWindows(t *testing.T) { + body := []byte(`{ + "five_hour": {"utilization": 30.0, "resets_at": "2026-03-20T12:00:00Z"}, + "seven_day": {"utilization": 10.0, "resets_at": "2026-03-25T00:00:00Z"}, + "seven_day_haiku": {"utilization": 35.0, "resets_at": "2026-03-24T00:00:00Z"} + }`) + + result := parseUsage(body, "max", "default_claude_max_20x", "user@example.com", "abc-123") + + window, ok := result.Windows[quota.WindowName("7d:haiku")] + if !ok { + t.Fatal("missing 7d:haiku window") + } + if window.RemainingPct != 65 { + t.Errorf("7d:haiku remaining_pct = %d, want 65", window.RemainingPct) + } + if window.ResetAtUnix == 0 { + t.Error("7d:haiku reset_at_unix should be non-zero") + } +} + +func TestParseUsageIncludesLimitsArrayScopedWindows(t *testing.T) { + body := []byte(`{ + "limits": [ + { + "kind": "session", + "group": "session", + "percent": 33, + "resets_at": "2026-07-10T06:30:00Z", + "scope": null + }, + { + "kind": "weekly_all", + "group": "weekly", + "percent": 13, + "resets_at": "2026-07-14T07:00:00Z", + "scope": null + }, + { + "kind": "weekly_scoped", + "group": "weekly", + "percent": 19, + "resets_at": "2026-07-14T07:00:00Z", + "scope": { + "model": { + "id": null, + "display_name": "Fable" + }, + "surface": null + } + } + ] + }`) + + result := parseUsage(body, "max", "default_claude_max_20x", "user@example.com", "abc-123") + + tests := []struct { + name string + want int + }{ + {name: "5h", want: 67}, + {name: "7d", want: 87}, + {name: "7d:fable", want: 81}, + } + for _, tc := range tests { + window, ok := result.Windows[quota.WindowName(tc.name)] + if !ok { + t.Fatalf("missing %s window", tc.name) + } + if window.RemainingPct != tc.want { + t.Errorf("%s remaining_pct = %d, want %d", tc.name, window.RemainingPct, tc.want) + } + if window.ResetAtUnix == 0 { + t.Errorf("%s reset_at_unix should be non-zero", tc.name) + } + } +} + +func TestParseUsageIgnoresNullBackendFedWindows(t *testing.T) { + body := []byte(`{ + "five_hour": {"utilization": 30.0, "resets_at": "2026-03-20T12:00:00Z"}, + "seven_day": {"utilization": 10.0, "resets_at": "2026-03-25T00:00:00Z"}, + "seven_day_fable": null + }`) + + result := parseUsage(body, "max", "default_claude_max_20x", "user@example.com", "abc-123") + + if _, ok := result.Windows[quota.WindowName("7d:fable")]; ok { + t.Fatal("unexpected 7d:fable window") + } +} + func TestParseUsageDoesNotEmitDesignWindowWhenOmeletteAbsent(t *testing.T) { result := parseUsage(usageJSON, "max", "default_claude_max_20x", "user@example.com", "abc-123")