summaryrefslogtreecommitdiff
path: root/internal/budget/budget.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-06-03 23:01:39 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-06-03 23:01:39 +0000
commit68cf1e24dd2ca2612b72babc4b35280cd3512f92 (patch)
tree14b73f21570a2f42ae729ca7e9676de6628d6819 /internal/budget/budget.go
parentb28cfc6ff288d083f6c8e9c055b69bfcadbceccc (diff)
parent7388337d3be5cc7f65ef547e30b2e39884dd165b (diff)
merge: integrate oss branch — budget gating, MCP back-channel, events, loopback-only bind
Key features from OSS branch: - Budget gating: rolling per-provider spend caps with BUDGET_EXCEEDED task state - Agent MCP back-channel: runners mint tokens; /mcp endpoint resolves them - Chatbot MCP server at /chatbot/mcp (requires api_token in config) - Event timeline: unified event stream replacing ad-hoc question/summary flows - Loopback-only default bind (127.0.0.1:8484); external_bind_allowed=true to expose - repository_url required on task creation (enforces traceability) - Auto-checker: spawns verification task after each execution Conflict resolutions: - serve.go: keep cfg.Runners.XEnabled() conditional registration from main + agentRegistry from oss - config.go: keep RunnersConfig from main + BudgetConfig/ExternalBindAllowed from oss - server.go: handleStaticFiles (base-path rewrite) kept; deduplicated duplicate from both sides - executor/claude.go: accepted oss deletion (ClaudeRunner replaced by ContainerRunner) - container_test.go: added noopChannel + AgentChannel parameter to Run call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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
+}