JuliusBrussee/caveman · error

stats days must be between 0 and 3660

Error message

stats days must be between 0 and 3660

What it means

BuildStatsReport validates StatsReportOptions.Days before reading the request ledger, accepting only 0 (all time) through 3660 days (10 years). Values outside that window are rejected because an absurd window would produce misleading rollups and expensive scans. 0 is explicitly allowed and means no lower-bound cutoff.

Solutions

  1. Clamp or validate the days value to 0..3660 before constructing StatsReportOptions
  2. Fix the date computation that produced a negative day count (swap start/end or take absolute value)
  3. If you truly need > 3660 days, chunk the report into multiple windows and merge

Example fix

// before
report, err := store.BuildStatsReport(StatsReportOptions{Days: days}) // days = 5000
// after
if days < 0 || days > 3660 {
    return fmt.Errorf("--days must be 0..3660")
}
report, err := store.BuildStatsReport(StatsReportOptions{Days: days})
Defensive patterns

Strategy: validation

Validate before calling

func normalizeDays(d int) (int, error) {
    if d < 0 || d > 3660 { return 0, fmt.Errorf("days must be 0..3660, got %d", d) }
    return d, nil
}

Prevention

When it happens

Trigger: Calling BuildStatsReport with StatsReportOptions{Days: n} where n is negative or greater than 3660, e.g. --days 9999 or a negative value computed from dates.

Common situations: A CLI script computing days from two dates where start > end yields a negative number; a typo like 36060 instead of 360; forwarding a raw user-supplied value without clamping.

Understand the failure class

Background: "value must be between 0 and 1" / "out of range" / "must not be negative" errors: fixing range-validation failures across open-source libraries — this error's family across 42 libraries.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20). Data as JSON: /api/errors/dcc8ef05754e87c2. Report an issue: GitHub.

Appendix: source

Thrown at proxy/internal/store/stats.go:197

			value := float64(delta) / 1_000_000 * rate
			if finiteStatsNumber(value) && math.Abs(value) <= math.MaxFloat64/10_000_000_000 {
				value = cost.RoundUSD(value)
				rec.RequestEstimatedInputDeltaUSD = &value
			} else {
				rec.RequestEstimatedInputDeltaUSD = nil
			}
		} else {
			rec.RequestEstimatedInputDeltaUSD = nil
		}
	}
}

// BuildStatsReport reads the request ledger only. Transcript imports are not
// unioned into this report: without an exact request join that would double count
// routed traffic and attach savings to unrelated agent usage.
func (s *Store) BuildStatsReport(opts StatsReportOptions) (StatsReport, error) {
	if opts.Days < 0 || opts.Days > 3660 {
		return StatsReport{}, fmt.Errorf("stats days must be between 0 and 3660")
	}
	now := opts.Now.UTC()
	if opts.Now.IsZero() {
		now = time.Now().UTC()
	}
	out := StatsReport{
		Schema: "caveman.stats.v1", GeneratedAt: now.Format(time.RFC3339),
		Window: StatsWindow{To: now.Format(time.RFC3339), Days: opts.Days, Lifetime: opts.Days == 0},
		Groups: []StatsGroup{}, Daily: []StatsGroup{}, Providers: []StatsGroup{}, Models: []StatsGroup{}, Agents: []StatsGroup{}, AuthModes: []StatsGroup{}, Receipts: []StatsReceipt{},
		Evidence: StatsEvidence{Basis: "inferred", TokenBasis: "estimated_request_json_o200k_v1", SpendBasis: "provider_counted_x_snapshotted_published_rate", CatalogVersions: []string{}, MeasurementStatuses: map[string]int64{}, ReceiptsLimit: statsReceiptLimit},
	}
	// Older ChatGPT rows used RFC3339 while the generic route used storeTSLayout.
	// Compare instants, not their differing separators or timezone offsets.
	where := []string{"julianday(ts) <= julianday(?)"}
	args := []any{now.Format(time.RFC3339Nano)}
	if opts.Days > 0 {
		from := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, 1-opts.Days)
		out.Window.From = from.Format(time.RFC3339)

View on GitHub (pinned to 3ee70a1026)