// Package budget tracks agent spend against rolling per-provider windows so the // dispatcher can refuse (or reroute) paid work that would breach a cap. Local // runners are free and simply carry no configured limit. package budget import ( "sort" "time" ) // DefaultWindow is the rolling spend window when none is configured. const DefaultWindow = 5 * time.Hour // Limits configures per-provider spend caps within a rolling window. type Limits struct { // Window is the rolling lookback; DefaultWindow when zero. Window time.Duration // PerProvider maps a provider (claude/gemini/…) to its USD cap within the // window. A missing or non-positive entry means "unlimited" (e.g. local). PerProvider map[string]float64 } // SpendSource supplies total spend per provider since a given time. type SpendSource interface { SpendByProviderSince(since time.Time) (map[string]float64, error) } // Headroom describes remaining budget for one provider in the current window. type Headroom struct { Provider string `json:"provider"` Limited bool `json:"limited"` Limit float64 `json:"limit_usd"` Spent float64 `json:"spent_usd"` Remaining float64 `json:"remaining_usd"` // Fraction is the share of the cap still available, 0..1 (0 when unlimited). Fraction float64 `json:"fraction_remaining"` } // Accountant answers spend questions against a SpendSource and Limits. type Accountant struct { src SpendSource lim Limits now func() time.Time } // New builds an Accountant. A zero Window defaults to DefaultWindow. func New(src SpendSource, lim Limits) *Accountant { if lim.Window <= 0 { lim.Window = DefaultWindow } return &Accountant{src: src, lim: lim, now: time.Now} } func (a *Accountant) since() time.Time { return a.now().Add(-a.lim.Window) } func (a *Accountant) headroomFrom(provider string, spends map[string]float64) Headroom { limit := a.lim.PerProvider[provider] if limit <= 0 { return Headroom{Provider: provider, Limited: false} } spent := spends[provider] remaining := limit - spent if remaining < 0 { remaining = 0 } return Headroom{ Provider: provider, Limited: true, Limit: limit, Spent: spent, Remaining: remaining, Fraction: remaining / limit, } } // Headroom returns the remaining budget for one provider in the current window. func (a *Accountant) Headroom(provider string) (Headroom, error) { if a.lim.PerProvider[provider] <= 0 { return Headroom{Provider: provider, Limited: false}, nil } spends, err := a.src.SpendByProviderSince(a.since()) if err != nil { return Headroom{}, err } return a.headroomFrom(provider, spends), nil } // Allow reports whether starting a task on provider that may cost up to estCost // is permitted without breaching the window cap. Unlimited providers always // pass. estCost is typically the task's max_budget_usd (0 = unknown). func (a *Accountant) Allow(provider string, estCost float64) (bool, error) { h, err := a.Headroom(provider) if err != nil { return false, err } if !h.Limited { return true, nil } return h.Spent+estCost <= h.Limit, nil } // All returns headroom for every configured provider, sorted by name, in a // single spend query. func (a *Accountant) All() ([]Headroom, error) { if len(a.lim.PerProvider) == 0 { return []Headroom{}, nil } spends, err := a.src.SpendByProviderSince(a.since()) if err != nil { return nil, err } providers := make([]string, 0, len(a.lim.PerProvider)) for p := range a.lim.PerProvider { providers = append(providers, p) } sort.Strings(providers) out := make([]Headroom, 0, len(providers)) for _, p := range providers { out = append(out, a.headroomFrom(p, spends)) } return out, nil }