From 39f74dbbc7adca0e2058112408bb99694deefcaf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 20:27:36 +0000 Subject: feat(budget,storage,config): per-provider spend accounting substrate (Phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/storage/db_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) (limited to 'internal/storage/db_test.go') diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index 0e67e02..4d744a4 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -309,6 +309,44 @@ func TestListTasks_WithLimit(t *testing.T) { } } +func TestSpendByProviderSince(t *testing.T) { + db := testDB(t) + now := time.Now().UTC() + + tk := &task.Task{ + ID: "spend-task", Name: "S", Agent: task.AgentConfig{Type: "claude", Instructions: "x"}, + Priority: task.PriorityNormal, Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, + Tags: []string{}, DependsOn: []string{}, State: task.StatePending, CreatedAt: now, UpdatedAt: now, + } + if err := db.CreateTask(tk); err != nil { + t.Fatal(err) + } + + // Two recent claude execs, one recent gemini, one old claude (outside window). + execs := []*Execution{ + {ID: "s1", TaskID: "spend-task", StartTime: now.Add(-1 * time.Hour), Status: "COMPLETED", CostUSD: 1.5, Agent: "claude"}, + {ID: "s2", TaskID: "spend-task", StartTime: now.Add(-2 * time.Hour), Status: "COMPLETED", CostUSD: 2.0, Agent: "claude"}, + {ID: "s3", TaskID: "spend-task", StartTime: now.Add(-30 * time.Minute), Status: "COMPLETED", CostUSD: 0.75, Agent: "gemini"}, + {ID: "s4", TaskID: "spend-task", StartTime: now.Add(-10 * time.Hour), Status: "COMPLETED", CostUSD: 99, Agent: "claude"}, + } + for _, e := range execs { + if err := db.CreateExecution(e); err != nil { + t.Fatalf("create exec %s: %v", e.ID, err) + } + } + + spends, err := db.SpendByProviderSince(now.Add(-5 * time.Hour)) + if err != nil { + t.Fatalf("SpendByProviderSince: %v", err) + } + if got := spends["claude"]; got != 3.5 { + t.Errorf("claude spend: want 3.5 (old $99 excluded), got %v", got) + } + if got := spends["gemini"]; got != 0.75 { + t.Errorf("gemini spend: want 0.75, got %v", got) + } +} + func TestCreateExecution_AndGet(t *testing.T) { db := testDB(t) now := time.Now().UTC().Truncate(time.Second) -- cgit v1.2.3