summaryrefslogtreecommitdiff
path: root/internal/budget/budget.go
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-05-26 20:27:36 +0000
committerClaude <noreply@anthropic.com>2026-05-26 20:27:36 +0000
commit39f74dbbc7adca0e2058112408bb99694deefcaf (patch)
treefb657954c1eba928567403b0e320b976e23f734a /internal/budget/budget.go
parent561915c5182c3fb39cd6a8b6613c489b35b7c1bf (diff)
feat(budget,storage,config): per-provider spend accounting substrate (Phase 6)
Adds the budget accountant foundation: - storage: a per-execution `agent` column (additive migration, populated by the dispatcher) and SpendByProviderSince(since), summing cost_usd per provider in a window. Accurate attribution survives a task running on different providers across retries. - internal/budget: Accountant over a SpendSource + Limits, exposing Headroom (remaining/fraction per provider), Allow (would estCost breach the cap), and All (one query, sorted). Unconfigured/local providers are unlimited. - config: a [budget] section (window + provider_5h_usd map). No default cap — gating is opt-in by configuring limits. Fully unit-tested; dispatcher gating and the API/UI surface follow. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39
Diffstat (limited to 'internal/budget/budget.go')
-rw-r--r--internal/budget/budget.go122
1 files changed, 122 insertions, 0 deletions
diff --git a/internal/budget/budget.go b/internal/budget/budget.go
new file mode 100644
index 0000000..60ce9d2
--- /dev/null
+++ b/internal/budget/budget.go
@@ -0,0 +1,122 @@
+// 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
+}