Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 156 additions & 34 deletions internal/provider/claude/parser.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package claude

import (
"bytes"
"encoding/json"
"fmt"
"math"
"sort"
"strings"

"github.com/jacobcxdev/cq/internal/quota"
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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{
Expand All @@ -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
Expand Down
96 changes: 94 additions & 2 deletions internal/provider/claude/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
}`)
Expand All @@ -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},
}
Expand All @@ -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")

Expand Down