From 130c5c0c38d9aac6821896900d4a0946e82e1bcf Mon Sep 17 00:00:00 2001 From: Jacob Clayden Date: Mon, 13 Jul 2026 20:51:50 +0100 Subject: [PATCH] fix: derived quota windows from duration - Used provider-reported periods for shared and scoped quota windows. - Added canonical duration keys and generic period calculations. - Reset stale burn history after unobserved window periods. --- internal/aggregate/aggregate_test.go | 28 ++++ internal/history/store.go | 12 ++ internal/history/store_test.go | 38 ++++++ internal/output/tty_build_test.go | 29 ++++ internal/provider/codex/parser.go | 81 ++++++------ internal/provider/codex/parser_test.go | 161 +++++++++++++++++++---- internal/provider/codex/provider_test.go | 2 +- internal/quota/constants.go | 107 ++++++++++++--- internal/quota/result_test.go | 81 +++++++++++- 9 files changed, 455 insertions(+), 84 deletions(-) diff --git a/internal/aggregate/aggregate_test.go b/internal/aggregate/aggregate_test.go index 658e252..f745cd2 100644 --- a/internal/aggregate/aggregate_test.go +++ b/internal/aggregate/aggregate_test.go @@ -547,6 +547,34 @@ func TestComputeAggregatesDynamicSevenDayWindow(t *testing.T) { } } +func TestComputeAggregatesGenericDurationWindow(t *testing.T) { + now := int64(1_000) + key := quota.WindowName("1d") + results := []quota.Result{ + { + Status: quota.StatusOK, + Windows: map[quota.WindowName]quota.Window{ + key: {RemainingPct: 80, ResetAtUnix: now + 43_200}, + }, + }, + { + Status: quota.StatusOK, + Windows: map[quota.WindowName]quota.Window{ + key: {RemainingPct: 60, ResetAtUnix: now + 43_200}, + }, + }, + } + + agg, _ := Compute(results, now, nil) + got, ok := agg[key] + if !ok { + t.Fatal("missing aggregate 1d window") + } + if got.RemainingPct != 70 || got.ExpectedPct != 50 || got.PaceDiff != 20 { + t.Fatalf("aggregate 1d = %#v, want remaining=70 expected=50 pace=20", got) + } +} + func TestComputeGatesDynamic5hWithMatching7dBucket(t *testing.T) { now := int64(1_000) fiveHour := quota.WindowName("5h:gpt-5.3-codex-spark") diff --git a/internal/history/store.go b/internal/history/store.go index aec4f9a..5f365b8 100644 --- a/internal/history/store.go +++ b/internal/history/store.go @@ -231,6 +231,18 @@ func (s *Store) UpdateAndGetBurnRates( // a zero-delta would produce a divide-by-zero below. continue } + period := int64(quota.PeriodFor(winName).Seconds()) + if period > 0 && dt > period { + // A gap longer than the window cannot distinguish suspended or + // unobserved resets from actual consumption. Cold-start instead. + *prev = WindowState{ + LastSeenUnix: nowEpoch, + LastRemainingPct: w.RemainingPct, + LastResetAtUnix: w.ResetAtUnix, + Samples: 1, + } + continue + } deltaPct, resetOK := computeDelta(prev, w, winName) if !resetOK { diff --git a/internal/history/store_test.go b/internal/history/store_test.go index 0e4e3af..612be71 100644 --- a/internal/history/store_test.go +++ b/internal/history/store_test.go @@ -113,6 +113,44 @@ func TestStoreSecondObservationEWMAUpdate(t *testing.T) { } } +func TestStoreReseedsAfterObservationGapExceedsWindowPeriod(t *testing.T) { + s, _ := newTestStore(t) + ctx := context.Background() + now := int64(1_000_000) + period := int64(quota.PeriodFor(quota.Window5Hour).Seconds()) + reset := now + 9_000 + + _, err := s.UpdateAndGetBurnRates(ctx, []quota.Result{ + makeResult("acct1", map[quota.WindowName]quota.Window{ + quota.Window5Hour: {RemainingPct: 80, ResetAtUnix: reset}, + }), + }, now) + if err != nil { + t.Fatalf("seed: %v", err) + } + + rates, err := s.UpdateAndGetBurnRates(ctx, []quota.Result{ + makeResult("acct1", map[quota.WindowName]quota.Window{ + quota.Window5Hour: {RemainingPct: 70, ResetAtUnix: reset + period}, + }), + }, now+period+60) + if err != nil { + t.Fatalf("resume: %v", err) + } + if _, ok := rates.Get(BurnRateKey{AccountKey: "acct1", Window: "5h"}); ok { + t.Fatal("stale observation produced burn rate after period-sized gap") + } + + state, err := s.load() + if err != nil { + t.Fatalf("load: %v", err) + } + w := state.Accounts["acct1"].Windows["5h"] + if w.Samples != 1 || w.EWMARatePctPerS != 0 || w.LastRemainingPct != 70 { + t.Fatalf("reseeded state = %#v, want fresh sample at 70%%", w) + } +} + func TestStoreResetUnwrap(t *testing.T) { s, _ := newTestStore(t) ctx := context.Background() diff --git a/internal/output/tty_build_test.go b/internal/output/tty_build_test.go index 55e441f..6019f2b 100644 --- a/internal/output/tty_build_test.go +++ b/internal/output/tty_build_test.go @@ -68,6 +68,35 @@ func TestBuildTTYModel_SingleProvider(t *testing.T) { } } +func TestBuildTTYModel_GenericDurationWindowUsesExactPeriod(t *testing.T) { + now := time.Unix(1_000, 0) + report := app.Report{ + GeneratedAt: now, + Providers: []app.ProviderReport{{ + ID: provider.Codex, + Name: "codex", + Results: []quota.Result{{ + Status: quota.StatusOK, + Windows: map[quota.WindowName]quota.Window{ + "1d": {RemainingPct: 75, ResetAtUnix: now.Unix() + 43_200}, + }, + }}, + }}, + } + + model := BuildTTYModel(report, now) + if len(model.Sections) != 1 || len(model.Sections[0].WindowRows) != 1 { + t.Fatalf("model = %#v, want one 1d row", model) + } + row := model.Sections[0].WindowRows[0] + if got := strings.TrimSpace(stripANSI(row.Label)); got != "1d" { + t.Errorf("label = %q, want 1d", got) + } + if !strings.Contains(stripANSI(row.PaceDiff), "+25") { + t.Errorf("pace diff = %q, want +25", stripANSI(row.PaceDiff)) + } +} + func TestBuildTTYModel_ErrorResult(t *testing.T) { now := time.Unix(1000, 0) report := app.Report{ diff --git a/internal/provider/codex/parser.go b/internal/provider/codex/parser.go index 401e2d6..919e214 100644 --- a/internal/provider/codex/parser.go +++ b/internal/provider/codex/parser.go @@ -14,32 +14,25 @@ var ( codexPromoEndsAt = time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC) ) +type usageWindow struct { + UsedPercent float64 `json:"used_percent"` + LimitWindowSeconds int64 `json:"limit_window_seconds"` + ResetAt any `json:"reset_at"` +} + +type usageRateLimit struct { + PrimaryWindow *usageWindow `json:"primary_window"` + SecondaryWindow *usageWindow `json:"secondary_window"` +} + // parseUsage decodes a Codex usage API JSON body and returns a quota.Result. func parseUsage(body []byte, email, accountID string) quota.Result { var usage struct { - PlanType string `json:"plan_type"` - RateLimit *struct { - PrimaryWindow *struct { - UsedPercent float64 `json:"used_percent"` - ResetAt any `json:"reset_at"` - } `json:"primary_window"` - SecondaryWindow *struct { - UsedPercent float64 `json:"used_percent"` - ResetAt any `json:"reset_at"` - } `json:"secondary_window"` - } `json:"rate_limit"` + PlanType string `json:"plan_type"` + RateLimit *usageRateLimit `json:"rate_limit"` AdditionalRateLimits []struct { - LimitName string `json:"limit_name"` - RateLimit *struct { - PrimaryWindow *struct { - UsedPercent float64 `json:"used_percent"` - ResetAt any `json:"reset_at"` - } `json:"primary_window"` - SecondaryWindow *struct { - UsedPercent float64 `json:"used_percent"` - ResetAt any `json:"reset_at"` - } `json:"secondary_window"` - } `json:"rate_limit"` + LimitName string `json:"limit_name"` + RateLimit *usageRateLimit `json:"rate_limit"` } `json:"additional_rate_limits"` } if err := json.Unmarshal(body, &usage); err != nil { @@ -52,30 +45,42 @@ func parseUsage(body []byte, email, accountID string) quota.Result { return quota.Window{RemainingPct: pct, ResetAtUnix: parseNumericResetAt(resetAt)} } - // Free accounts have only a weekly limit; their primary_window is a 7d window. - primaryWindowName := quota.Window5Hour - if usage.PlanType == "free" { - primaryWindowName = quota.Window7Day - } - windows := make(map[quota.WindowName]quota.Window) - if usage.RateLimit != nil { - if usage.RateLimit.PrimaryWindow != nil { - windows[primaryWindowName] = toWindow(usage.RateLimit.PrimaryWindow.UsedPercent, usage.RateLimit.PrimaryWindow.ResetAt) + addWindows := func(rateLimit *usageRateLimit, bucket string) error { + if rateLimit == nil { + return nil } - if usage.RateLimit.SecondaryWindow != nil { - windows[quota.Window7Day] = toWindow(usage.RateLimit.SecondaryWindow.UsedPercent, usage.RateLimit.SecondaryWindow.ResetAt) + for _, entry := range []struct { + slot string + window *usageWindow + }{ + {slot: "primary_window", window: rateLimit.PrimaryWindow}, + {slot: "secondary_window", window: rateLimit.SecondaryWindow}, + } { + if entry.window == nil { + continue + } + name, ok := quota.WindowNameForPeriod(entry.window.LimitWindowSeconds, bucket) + if !ok { + return fmt.Errorf("invalid %s limit_window_seconds", entry.slot) + } + if _, exists := windows[name]; exists { + return fmt.Errorf("conflicting rate limit window %q", name) + } + windows[name] = toWindow(entry.window.UsedPercent, entry.window.ResetAt) } + return nil + } + + if err := addWindows(usage.RateLimit, ""); err != nil { + return quota.ErrorResult("parse_error", fmt.Sprintf("parse: %v", err), 0) } for _, extra := range usage.AdditionalRateLimits { if extra.LimitName == "" || extra.RateLimit == nil { continue } - if extra.RateLimit.PrimaryWindow != nil { - windows[quota.WindowName("5h:"+extra.LimitName)] = toWindow(extra.RateLimit.PrimaryWindow.UsedPercent, extra.RateLimit.PrimaryWindow.ResetAt) - } - if extra.RateLimit.SecondaryWindow != nil { - windows[quota.WindowName("7d:"+extra.LimitName)] = toWindow(extra.RateLimit.SecondaryWindow.UsedPercent, extra.RateLimit.SecondaryWindow.ResetAt) + if err := addWindows(extra.RateLimit, extra.LimitName); err != nil { + return quota.ErrorResult("parse_error", fmt.Sprintf("parse: %v", err), 0) } } diff --git a/internal/provider/codex/parser_test.go b/internal/provider/codex/parser_test.go index eb99f3c..99eee6f 100644 --- a/internal/provider/codex/parser_test.go +++ b/internal/provider/codex/parser_test.go @@ -10,8 +10,8 @@ import ( var codexUsageJSON = []byte(`{ "plan_type": "plus", "rate_limit": { - "primary_window": {"used_percent": 25.0, "reset_at": 1774051200}, - "secondary_window": {"used_percent": 10.0, "reset_at": 1774569600} + "primary_window": {"used_percent": 25.0, "limit_window_seconds": 18000, "reset_at": 1774051200}, + "secondary_window": {"used_percent": 10.0, "limit_window_seconds": 604800, "reset_at": 1774569600} } }`) @@ -58,8 +58,8 @@ func TestParseUsageExhausted(t *testing.T) { exhaustedJSON := []byte(`{ "plan_type": "plus", "rate_limit": { - "primary_window": {"used_percent": 100.0, "reset_at": 1774051200}, - "secondary_window": {"used_percent": 50.0, "reset_at": 1774569600} + "primary_window": {"used_percent": 100.0, "limit_window_seconds": 18000, "reset_at": 1774051200}, + "secondary_window": {"used_percent": 50.0, "limit_window_seconds": 604800, "reset_at": 1774569600} } }`) @@ -109,7 +109,7 @@ func TestParseUsageFreeOnlyPrimaryWindowMapsTo7d(t *testing.T) { onlyPrimaryJSON := []byte(`{ "plan_type": "free", "rate_limit": { - "primary_window": {"used_percent": 30.0, "reset_at": 1774051200} + "primary_window": {"used_percent": 30.0, "limit_window_seconds": 604800, "reset_at": 1774051200} } }`) @@ -139,7 +139,7 @@ func TestParseUsageFreeOnlyPrimaryWindowMapsTo7d(t *testing.T) { func TestParseUsageUnknownPlanType(t *testing.T) { noPlanJSON := []byte(`{ "rate_limit": { - "primary_window": {"used_percent": 10.0, "reset_at": 1774051200} + "primary_window": {"used_percent": 10.0, "limit_window_seconds": 18000, "reset_at": 1774051200} } }`) @@ -158,7 +158,7 @@ func TestParseUsageProMultiplier(t *testing.T) { proJSON := []byte(`{ "plan_type": "pro", "rate_limit": { - "primary_window": {"used_percent": 10.0, "reset_at": 1774051200} + "primary_window": {"used_percent": 10.0, "limit_window_seconds": 18000, "reset_at": 1774051200} } }`) @@ -183,7 +183,7 @@ func TestParseUsageProMultiplierAfterPromoEnds(t *testing.T) { proJSON := []byte(`{ "plan_type": "pro", "rate_limit": { - "primary_window": {"used_percent": 10.0, "reset_at": 1774051200} + "primary_window": {"used_percent": 10.0, "limit_window_seconds": 18000, "reset_at": 1774051200} } }`) @@ -205,7 +205,7 @@ func TestParseUsageProLiteMultiplier(t *testing.T) { proLiteJSON := []byte(`{ "plan_type": "prolite", "rate_limit": { - "primary_window": {"used_percent": 10.0, "reset_at": 1774051200} + "primary_window": {"used_percent": 10.0, "limit_window_seconds": 18000, "reset_at": 1774051200} } }`) @@ -230,7 +230,7 @@ func TestParseUsageProLiteMultiplierAfterPromoEnds(t *testing.T) { proLiteJSON := []byte(`{ "plan_type": "prolite", "rate_limit": { - "primary_window": {"used_percent": 10.0, "reset_at": 1774051200} + "primary_window": {"used_percent": 10.0, "limit_window_seconds": 18000, "reset_at": 1774051200} } }`) @@ -276,7 +276,7 @@ func TestParseUsageSparkNotEncodedInPlanMultiplier(t *testing.T) { result := parseUsage([]byte(`{ "plan_type": "pro", "rate_limit": { - "primary_window": {"used_percent": 10.0, "reset_at": 1774051200} + "primary_window": {"used_percent": 10.0, "limit_window_seconds": 18000, "reset_at": 1774051200} } }`), "user@example.com", "") @@ -293,7 +293,7 @@ func TestParseUsageBusinessDoesNotReceivePromoMultiplier(t *testing.T) { result := parseUsage([]byte(`{ "plan_type": "business", "rate_limit": { - "primary_window": {"used_percent": 10.0, "reset_at": 1774051200} + "primary_window": {"used_percent": 10.0, "limit_window_seconds": 18000, "reset_at": 1774051200} } }`), "user@example.com", "") @@ -310,7 +310,7 @@ func TestParseUsageEnterpriseDoesNotReceivePromoMultiplier(t *testing.T) { result := parseUsage([]byte(`{ "plan_type": "enterprise", "rate_limit": { - "primary_window": {"used_percent": 10.0, "reset_at": 1774051200} + "primary_window": {"used_percent": 10.0, "limit_window_seconds": 18000, "reset_at": 1774051200} } }`), "user@example.com", "") @@ -327,7 +327,7 @@ func TestParseUsageEduDoesNotReceivePromoMultiplier(t *testing.T) { result := parseUsage([]byte(`{ "plan_type": "edu", "rate_limit": { - "primary_window": {"used_percent": 10.0, "reset_at": 1774051200} + "primary_window": {"used_percent": 10.0, "limit_window_seconds": 18000, "reset_at": 1774051200} } }`), "user@example.com", "") @@ -344,7 +344,7 @@ func TestParseUsageGoDoesNotReceivePromoMultiplier(t *testing.T) { result := parseUsage([]byte(`{ "plan_type": "go", "rate_limit": { - "primary_window": {"used_percent": 10.0, "reset_at": 1774051200} + "primary_window": {"used_percent": 10.0, "limit_window_seconds": 18000, "reset_at": 1774051200} } }`), "user@example.com", "") @@ -361,7 +361,7 @@ func TestParseUsageFreeDoesNotReceivePromoMultiplier(t *testing.T) { result := parseUsage([]byte(`{ "plan_type": "free", "rate_limit": { - "primary_window": {"used_percent": 10.0, "reset_at": 1774051200} + "primary_window": {"used_percent": 10.0, "limit_window_seconds": 18000, "reset_at": 1774051200} } }`), "user@example.com", "") @@ -378,7 +378,7 @@ func TestParseUsageGuestDoesNotReceivePromoMultiplier(t *testing.T) { result := parseUsage([]byte(`{ "plan_type": "guest", "rate_limit": { - "primary_window": {"used_percent": 10.0, "reset_at": 1774051200} + "primary_window": {"used_percent": 10.0, "limit_window_seconds": 18000, "reset_at": 1774051200} } }`), "user@example.com", "") @@ -395,7 +395,7 @@ func TestParseUsageFreeWorkspaceDoesNotReceivePromoMultiplier(t *testing.T) { result := parseUsage([]byte(`{ "plan_type": "free_workspace", "rate_limit": { - "primary_window": {"used_percent": 10.0, "reset_at": 1774051200} + "primary_window": {"used_percent": 10.0, "limit_window_seconds": 18000, "reset_at": 1774051200} } }`), "user@example.com", "") @@ -412,7 +412,7 @@ func TestParseUsageQuorumDoesNotReceivePromoMultiplier(t *testing.T) { result := parseUsage([]byte(`{ "plan_type": "quorum", "rate_limit": { - "primary_window": {"used_percent": 10.0, "reset_at": 1774051200} + "primary_window": {"used_percent": 10.0, "limit_window_seconds": 18000, "reset_at": 1774051200} } }`), "user@example.com", "") @@ -429,7 +429,7 @@ func TestParseUsageK12DoesNotReceivePromoMultiplier(t *testing.T) { result := parseUsage([]byte(`{ "plan_type": "k12", "rate_limit": { - "primary_window": {"used_percent": 10.0, "reset_at": 1774051200} + "primary_window": {"used_percent": 10.0, "limit_window_seconds": 18000, "reset_at": 1774051200} } }`), "user@example.com", "") @@ -446,7 +446,7 @@ func TestParseUsageUnknownDoesNotReceivePromoMultiplier(t *testing.T) { result := parseUsage([]byte(`{ "plan_type": "mystery-tier", "rate_limit": { - "primary_window": {"used_percent": 10.0, "reset_at": 1774051200} + "primary_window": {"used_percent": 10.0, "limit_window_seconds": 18000, "reset_at": 1774051200} } }`), "user@example.com", "") @@ -463,7 +463,7 @@ func TestParseUsagePromoAppliesOnlyBeforeDeadline(t *testing.T) { proBefore := parseUsage([]byte(`{ "plan_type": "pro", "rate_limit": { - "primary_window": {"used_percent": 10.0, "reset_at": 1774051200} + "primary_window": {"used_percent": 10.0, "limit_window_seconds": 18000, "reset_at": 1774051200} } }`), "user@example.com", "") if proBefore.RateLimitTier != "codex_pro_20x" { @@ -474,7 +474,7 @@ func TestParseUsagePromoAppliesOnlyBeforeDeadline(t *testing.T) { proAfter := parseUsage([]byte(`{ "plan_type": "pro", "rate_limit": { - "primary_window": {"used_percent": 10.0, "reset_at": 1774051200} + "primary_window": {"used_percent": 10.0, "limit_window_seconds": 18000, "reset_at": 1774051200} } }`), "user@example.com", "") if proAfter.RateLimitTier != "codex_pro_10x" { @@ -490,7 +490,7 @@ func TestParseUsageProLitePromoAppliesOnlyBeforeDeadline(t *testing.T) { proLiteBefore := parseUsage([]byte(`{ "plan_type": "prolite", "rate_limit": { - "primary_window": {"used_percent": 10.0, "reset_at": 1774051200} + "primary_window": {"used_percent": 10.0, "limit_window_seconds": 18000, "reset_at": 1774051200} } }`), "user@example.com", "") if proLiteBefore.RateLimitTier != "codex_prolite_10x" { @@ -501,7 +501,7 @@ func TestParseUsageProLitePromoAppliesOnlyBeforeDeadline(t *testing.T) { proLiteAfter := parseUsage([]byte(`{ "plan_type": "prolite", "rate_limit": { - "primary_window": {"used_percent": 10.0, "reset_at": 1774051200} + "primary_window": {"used_percent": 10.0, "limit_window_seconds": 18000, "reset_at": 1774051200} } }`), "user@example.com", "") if proLiteAfter.RateLimitTier != "codex_prolite_5x" { @@ -534,16 +534,16 @@ func TestParseUsageAdditionalRateLimits(t *testing.T) { body := []byte(`{ "plan_type": "plus", "rate_limit": { - "primary_window": {"used_percent": 25.0, "reset_at": 1774051200}, - "secondary_window": {"used_percent": 10.0, "reset_at": 1774569600} + "primary_window": {"used_percent": 25.0, "limit_window_seconds": 18000, "reset_at": 1774051200}, + "secondary_window": {"used_percent": 10.0, "limit_window_seconds": 604800, "reset_at": 1774569600} }, "additional_rate_limits": [ { "limit_name": "gpt-5.3-codex-spark", "metered_feature": "responses", "rate_limit": { - "primary_window": {"used_percent": 40.0, "reset_at": 1774051300}, - "secondary_window": {"used_percent": 15.0, "reset_at": 1774569700} + "primary_window": {"used_percent": 40.0, "limit_window_seconds": 18000, "reset_at": 1774051300}, + "secondary_window": {"used_percent": 15.0, "limit_window_seconds": 604800, "reset_at": 1774569700} } } ] @@ -573,3 +573,106 @@ func TestParseUsageAdditionalRateLimits(t *testing.T) { t.Errorf("7d:gpt-5.3-codex-spark reset_at_unix = %d, want 1774569700", spark7d.ResetAtUnix) } } + +func TestParseUsageDerivesWeeklyOnlyWindowsFromDuration(t *testing.T) { + body := []byte(`{ + "plan_type": "pro", + "rate_limit": { + "primary_window": { + "used_percent": 7, + "limit_window_seconds": 604800, + "reset_at": 1784488012 + }, + "secondary_window": null + }, + "additional_rate_limits": [{ + "limit_name": "GPT-5.3-Codex-Spark", + "rate_limit": { + "primary_window": { + "used_percent": 0, + "limit_window_seconds": 604800, + "reset_at": 1784573327 + }, + "secondary_window": null + } + }] + }`) + + result := parseUsage(body, "user@example.com", "acct-1") + if result.Status != quota.StatusOK { + t.Fatalf("status = %q, want %q", result.Status, quota.StatusOK) + } + if _, ok := result.Windows[quota.Window5Hour]; ok { + t.Fatal("unexpected fabricated 5h window") + } + if got := result.Windows[quota.Window7Day].RemainingPct; got != 93 { + t.Errorf("7d remaining_pct = %d, want 93", got) + } + spark := quota.WindowName("7d:GPT-5.3-Codex-Spark") + if got := result.Windows[spark].RemainingPct; got != 100 { + t.Errorf("%s remaining_pct = %d, want 100", spark, got) + } +} + +func TestParseUsageWindowSlotsDoNotDetermineDuration(t *testing.T) { + body := []byte(`{ + "plan_type": "plus", + "rate_limit": { + "primary_window": {"used_percent": 10, "limit_window_seconds": 604800, "reset_at": 1774569600}, + "secondary_window": {"used_percent": 25, "limit_window_seconds": 18000, "reset_at": 1774051200} + } + }`) + + result := parseUsage(body, "user@example.com", "") + if got := result.Windows[quota.Window5Hour].RemainingPct; got != 75 { + t.Errorf("5h remaining_pct = %d, want 75", got) + } + if got := result.Windows[quota.Window7Day].RemainingPct; got != 90 { + t.Errorf("7d remaining_pct = %d, want 90", got) + } +} + +func TestParseUsageSupportsArbitraryWindowDuration(t *testing.T) { + body := []byte(`{ + "plan_type": "plus", + "rate_limit": { + "primary_window": {"used_percent": 30, "limit_window_seconds": 86400, "reset_at": 1774051200}, + "secondary_window": null + } + }`) + + result := parseUsage(body, "user@example.com", "") + if got := result.Windows[quota.WindowName("1d")].RemainingPct; got != 70 { + t.Errorf("1d remaining_pct = %d, want 70", got) + } +} + +func TestParseUsageRejectsMissingWindowDuration(t *testing.T) { + body := []byte(`{ + "plan_type": "plus", + "rate_limit": { + "primary_window": {"used_percent": 30, "reset_at": 1774051200}, + "secondary_window": null + } + }`) + + result := parseUsage(body, "user@example.com", "") + if result.Status != quota.StatusError || result.Error == nil || result.Error.Code != "parse_error" { + t.Fatalf("result = %#v, want parse_error", result) + } +} + +func TestParseUsageRejectsConflictingWindowDurations(t *testing.T) { + body := []byte(`{ + "plan_type": "plus", + "rate_limit": { + "primary_window": {"used_percent": 30, "limit_window_seconds": 604800, "reset_at": 1774051200}, + "secondary_window": {"used_percent": 20, "limit_window_seconds": 604800, "reset_at": 1774569600} + } + }`) + + result := parseUsage(body, "user@example.com", "") + if result.Status != quota.StatusError || result.Error == nil || result.Error.Code != "parse_error" { + t.Fatalf("result = %#v, want parse_error", result) + } +} diff --git a/internal/provider/codex/provider_test.go b/internal/provider/codex/provider_test.go index ac07073..60d35e3 100644 --- a/internal/provider/codex/provider_test.go +++ b/internal/provider/codex/provider_test.go @@ -167,7 +167,7 @@ func validAuthJSON(accessToken, refreshToken, idToken, accountID string) []byte } // happyUsageBody is a minimal valid usage API response. -const happyUsageBody = `{"plan_type":"plus","rate_limit":{"primary_window":{"used_percent":20.0,"reset_at":1774051200}}}` +const happyUsageBody = `{"plan_type":"plus","rate_limit":{"primary_window":{"used_percent":20.0,"limit_window_seconds":18000,"reset_at":1774051200}}}` func TestFetchMissingAuthFile(t *testing.T) { fs := newFakeFS() diff --git a/internal/quota/constants.go b/internal/quota/constants.go index 0c680bf..090b2be 100644 --- a/internal/quota/constants.go +++ b/internal/quota/constants.go @@ -2,6 +2,7 @@ package quota import ( "sort" + "strconv" "strings" "time" ) @@ -49,25 +50,76 @@ func DisplayWindowLabel(w WindowName) string { } func IsAggregable(w WindowName) bool { - switch BaseWindow(w) { - case Window5Hour, Window7Day: - return true - default: - return false - } + _, ok := durationPeriodFor(w) + return ok } func PeriodFor(name WindowName) time.Duration { switch BaseWindow(name) { - case Window5Hour: - return 5 * time.Hour - case Window7Day: - return 7 * 24 * time.Hour case WindowPro, WindowFlash, WindowFlashLite: return 24 * time.Hour + } + period, _ := durationPeriodFor(name) + return period +} + +// WindowNameForPeriod returns a canonical duration-backed window name. +// Periods use the largest exact whole unit and optionally include a bucket. +func WindowNameForPeriod(periodSeconds int64, bucket string) (WindowName, bool) { + const maxPeriodSeconds = int64(1<<63-1) / int64(time.Second) + if periodSeconds <= 0 || periodSeconds > maxPeriodSeconds { + return "", false + } + + var base string + switch { + case periodSeconds%int64(24*time.Hour/time.Second) == 0: + base = strconv.FormatInt(periodSeconds/int64(24*time.Hour/time.Second), 10) + "d" + case periodSeconds%int64(time.Hour/time.Second) == 0: + base = strconv.FormatInt(periodSeconds/int64(time.Hour/time.Second), 10) + "h" + case periodSeconds%int64(time.Minute/time.Second) == 0: + base = strconv.FormatInt(periodSeconds/int64(time.Minute/time.Second), 10) + "m" default: - return 0 + base = strconv.FormatInt(periodSeconds, 10) + "s" } + return scopedWindow(WindowName(base), bucket), true +} + +func durationPeriodFor(name WindowName) (time.Duration, bool) { + base := string(BaseWindow(name)) + if len(base) < 2 { + return 0, false + } + + value, err := strconv.ParseInt(base[:len(base)-1], 10, 64) + if err != nil || value <= 0 { + return 0, false + } + + var unitSeconds int64 + switch base[len(base)-1] { + case 'd': + unitSeconds = int64(24 * time.Hour / time.Second) + case 'h': + unitSeconds = int64(time.Hour / time.Second) + case 'm': + unitSeconds = int64(time.Minute / time.Second) + case 's': + unitSeconds = 1 + default: + return 0, false + } + + const maxPeriodSeconds = int64(1<<63-1) / int64(time.Second) + if value > maxPeriodSeconds/unitSeconds { + return 0, false + } + periodSeconds := value * unitSeconds + canonical, ok := WindowNameForPeriod(periodSeconds, "") + if !ok || BaseWindow(name) != canonical { + return 0, false + } + return time.Duration(periodSeconds) * time.Second, true } // OrderedWindows returns fixed window names in canonical display order. @@ -76,7 +128,7 @@ func OrderedWindows() []WindowName { } // OrderedWindowNames returns the provided windows in canonical display order: -// shared windows first, then bucket-scoped 5h/7d windows grouped by bucket, +// shared duration windows first, then scoped duration windows grouped by bucket, // then fixed provider-specific daily windows, then any remaining unknown keys. func OrderedWindowNames(keys []WindowName) []WindowName { present := make(map[WindowName]struct{}, len(keys)) @@ -97,7 +149,20 @@ func OrderedWindowNames(keys []WindowName) []WindowName { seen[name] = struct{}{} } - shared := []WindowName{Window5Hour, Window7Day} + shared := make([]WindowName, 0, len(present)) + for name := range present { + if WindowBucket(name) == "" && IsAggregable(name) { + shared = append(shared, name) + } + } + sort.Slice(shared, func(i, j int) bool { + pi := PeriodFor(shared[i]) + pj := PeriodFor(shared[j]) + if pi != pj { + return pi < pj + } + return shared[i] < shared[j] + }) for _, name := range shared { add(name) } @@ -123,7 +188,7 @@ func OrderedWindowNames(keys []WindowName) []WindowName { _, has5h := bases[Window5Hour] _, has7d := bases[Window7Day] switch { - case has7d && !has5h: + case len(bases) == 1 && has7d: return 0 case has5h: return 1 @@ -139,7 +204,19 @@ func OrderedWindowNames(keys []WindowName) []WindowName { return buckets[i] < buckets[j] }) for _, bucket := range buckets { - for _, base := range shared { + bases := make([]WindowName, 0, len(bucketBases[bucket])) + for base := range bucketBases[bucket] { + bases = append(bases, base) + } + sort.Slice(bases, func(i, j int) bool { + pi := PeriodFor(bases[i]) + pj := PeriodFor(bases[j]) + if pi != pj { + return pi < pj + } + return bases[i] < bases[j] + }) + for _, base := range bases { add(scopedWindow(base, bucket)) } } diff --git a/internal/quota/result_test.go b/internal/quota/result_test.go index 8b578f2..6eac87d 100644 --- a/internal/quota/result_test.go +++ b/internal/quota/result_test.go @@ -1,6 +1,9 @@ package quota -import "testing" +import ( + "math" + "testing" +) func TestResultIsUsable(t *testing.T) { tests := []struct { @@ -123,6 +126,10 @@ func TestPeriodFor(t *testing.T) { {WindowPro, 24 * 3600}, {WindowFlash, 24 * 3600}, {WindowFlashLite, 24 * 3600}, + {"1d", 24 * 3600}, + {"90m:scoped", 90 * 60}, + {"24h", 0}, + {"60m", 0}, {"unknown", 0}, } for _, tt := range tests { @@ -134,3 +141,75 @@ func TestPeriodFor(t *testing.T) { }) } } + +func TestWindowNameForPeriod(t *testing.T) { + tests := []struct { + name string + seconds int64 + bucket string + want WindowName + wantOK bool + }{ + {name: "five hours", seconds: 5 * 60 * 60, want: "5h", wantOK: true}, + {name: "one day", seconds: 24 * 60 * 60, want: "1d", wantOK: true}, + {name: "seven days", seconds: 7 * 24 * 60 * 60, want: "7d", wantOK: true}, + {name: "ninety minutes", seconds: 90 * 60, want: "90m", wantOK: true}, + {name: "scoped", seconds: 7 * 24 * 60 * 60, bucket: "spark", want: "7d:spark", wantOK: true}, + {name: "seconds", seconds: 61, want: "61s", wantOK: true}, + {name: "zero", seconds: 0}, + {name: "negative", seconds: -1}, + {name: "overflow", seconds: math.MaxInt64}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := WindowNameForPeriod(tt.seconds, tt.bucket) + if got != tt.want || ok != tt.wantOK { + t.Fatalf("WindowNameForPeriod(%d, %q) = (%q, %v), want (%q, %v)", tt.seconds, tt.bucket, got, ok, tt.want, tt.wantOK) + } + }) + } +} + +func TestGenericDurationWindowsAreAggregable(t *testing.T) { + for _, name := range []WindowName{"1d", "90m:scoped"} { + if !IsAggregable(name) { + t.Errorf("IsAggregable(%q) = false, want true", name) + } + } + for _, name := range []WindowName{WindowPro, WindowFlash, WindowFlashLite, "unknown"} { + if IsAggregable(name) { + t.Errorf("IsAggregable(%q) = true, want false", name) + } + } +} + +func TestOrderedWindowNamesGenericDurations(t *testing.T) { + got := OrderedWindowNames([]WindowName{ + "7d:spark", + "1d:spark", + WindowPro, + "1d", + Window7Day, + Window5Hour, + "7d:weekly-only", + }) + want := []WindowName{ + Window5Hour, + "1d", + Window7Day, + "7d:weekly-only", + "1d:spark", + "7d:spark", + WindowPro, + } + + if len(got) != len(want) { + t.Fatalf("OrderedWindowNames() length = %d, want %d (%v)", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("OrderedWindowNames()[%d] = %q, want %q (all=%v)", i, got[i], want[i], got) + } + } +}