-
Notifications
You must be signed in to change notification settings - Fork 0
feat: AI cost controls — per-provider, per-team, per-workflow token budgets #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 21 commits
d012699
8100636
f401167
851fc16
c9ea680
b5358ed
9344def
8d30122
6d6d12d
67bd8c7
3ab45ea
ef5c033
5f0fc5b
d891a6d
2b5115d
ca52898
9f2b8ca
096bdce
10306ca
2b4b035
57e5c34
9c61a8b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| package budget | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "time" | ||
| ) | ||
|
|
||
| // CurrentPeriodStart returns the start date of the current budget period. | ||
| // For "calendar" mode: first day of the current month. | ||
| // For "rolling" mode: the most recent occurrence of resetDay (1-28). | ||
| func CurrentPeriodStart(now time.Time, mode string, resetDay int) time.Time { | ||
| now = now.UTC() | ||
| if mode == "rolling" && resetDay >= 1 && resetDay <= 28 { | ||
| if now.Day() >= resetDay { | ||
| return time.Date(now.Year(), now.Month(), resetDay, 0, 0, 0, 0, time.UTC) | ||
| } | ||
| prev := now.AddDate(0, -1, 0) | ||
| return time.Date(prev.Year(), prev.Month(), resetDay, 0, 0, 0, 0, time.UTC) | ||
| } | ||
| return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| // CheckInput describes the context of a budget check. | ||
| type CheckInput struct { | ||
| TeamID string | ||
| Provider string | ||
| } | ||
|
|
||
| // CheckResult describes the outcome of a budget check. | ||
| type CheckResult struct { | ||
| Blocked bool | ||
| Warning bool | ||
| BlockedBy string // "global", "team", "workflow", or "" | ||
| Message string | ||
| } | ||
|
|
||
| // Checker evaluates budget limits before AI step dispatch. | ||
| // Uses function fields for DB access to allow easy testing with mocks. | ||
| type Checker struct { | ||
| GlobalMonthlyTokenLimit int64 | ||
| DefaultTeamMonthlyTokenLimit int64 | ||
| ResetMode string | ||
| ResetDay int | ||
|
|
||
| GetTotalUsage func(ctx context.Context, period time.Time) (int64, error) | ||
| GetProviderUsage func(ctx context.Context, teamID, provider string, period time.Time) (int64, error) | ||
| GetTeamBudget func(ctx context.Context, teamID, provider string) (*TeamBudget, error) | ||
| } | ||
|
|
||
| // Check evaluates all budget levels (global, team+provider) and returns the result. | ||
| // Budget levels compose as AND gates — all must pass. | ||
| func (c *Checker) Check(ctx context.Context, input CheckInput) CheckResult { | ||
| now := time.Now() | ||
| period := CurrentPeriodStart(now, c.ResetMode, c.ResetDay) | ||
|
|
||
| // 1. Global budget — hard block only | ||
| if c.GlobalMonthlyTokenLimit > 0 && c.GetTotalUsage != nil { | ||
| total, err := c.GetTotalUsage(ctx, period) | ||
| if err == nil && total >= c.GlobalMonthlyTokenLimit { | ||
| return CheckResult{ | ||
|
Comment on lines
+58
to
+61
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not fail open when budget reads error out. If any of these lookups returns an error, the function falls through to Also applies to: 70-79 🤖 Prompt for AI Agents |
||
| Blocked: true, | ||
| BlockedBy: "global", | ||
| Message: fmt.Sprintf("global monthly token limit exceeded (%d/%d)", total, c.GlobalMonthlyTokenLimit), | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+57
to
+67
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Budget checks fail-open on database errors. When For hard budgets, consider returning a blocking result when lookups fail to avoid an unlimited-spend window during outages. 🛡️ Proposed approach to fail-closed on errors // 1. Global budget — hard block only
if c.GlobalMonthlyTokenLimit > 0 && c.GetTotalUsage != nil {
total, err := c.GetTotalUsage(ctx, period)
+ if err != nil {
+ return CheckResult{
+ Blocked: true,
+ BlockedBy: "global",
+ Message: fmt.Sprintf("budget check failed: %v", err),
+ }
+ }
- if err == nil && total >= c.GlobalMonthlyTokenLimit {
+ if total >= c.GlobalMonthlyTokenLimit {Apply similar pattern to the team budget check. Also applies to: 81-97 🤖 Prompt for AI Agents |
||
|
|
||
| // 2. Team+provider budget — configurable enforcement | ||
| if c.GetTeamBudget != nil && c.GetProviderUsage != nil { | ||
| tb, err := c.GetTeamBudget(ctx, input.TeamID, input.Provider) | ||
| if err == nil && tb == nil { | ||
| tb, err = c.GetTeamBudget(ctx, input.TeamID, "*") | ||
| } | ||
| if err == nil && tb == nil && c.DefaultTeamMonthlyTokenLimit > 0 { | ||
| tb = &TeamBudget{ | ||
| MonthlyTokenLimit: c.DefaultTeamMonthlyTokenLimit, | ||
| Enforcement: "hard", | ||
| } | ||
| } | ||
| if err == nil && tb != nil && tb.MonthlyTokenLimit > 0 { | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| usage, err := c.GetProviderUsage(ctx, input.TeamID, input.Provider, period) | ||
| if err == nil && usage >= tb.MonthlyTokenLimit { | ||
| if tb.Enforcement == "warn" { | ||
|
Comment on lines
+69
to
+84
|
||
| return CheckResult{ | ||
| Warning: true, | ||
| BlockedBy: "team", | ||
| Message: fmt.Sprintf("team budget exceeded for provider %s (%d/%d tokens) — enforcement is warn-only", input.Provider, usage, tb.MonthlyTokenLimit), | ||
| } | ||
| } | ||
| return CheckResult{ | ||
| Blocked: true, | ||
| BlockedBy: "team", | ||
| Message: fmt.Sprintf("team budget exceeded for provider %s (%d/%d tokens)", input.Provider, usage, tb.MonthlyTokenLimit), | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return CheckResult{} | ||
| } | ||
|
|
||
| // CheckExecutionBudget checks if a workflow execution's cumulative token usage | ||
| // has exceeded the workflow-level token_budget. budgetLimit of 0 means unlimited. | ||
| func CheckExecutionBudget(budgetLimit int64, usedTokens int64) CheckResult { | ||
| if budgetLimit <= 0 { | ||
| return CheckResult{} | ||
| } | ||
| if usedTokens >= budgetLimit { | ||
| return CheckResult{ | ||
| Blocked: true, | ||
| BlockedBy: "workflow", | ||
| Message: fmt.Sprintf("workflow token budget exceeded (%d/%d)", usedTokens, budgetLimit), | ||
| } | ||
| } | ||
| return CheckResult{} | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,216 @@ | ||
| package budget_test | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/dvflw/mantle/internal/budget" | ||
| "github.com/stretchr/testify/assert" | ||
| ) | ||
|
|
||
| func TestCurrentPeriodStart_Calendar(t *testing.T) { | ||
| now := time.Date(2026, 3, 23, 14, 30, 0, 0, time.UTC) | ||
| start := budget.CurrentPeriodStart(now, "calendar", 1) | ||
| assert.Equal(t, time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC), start) | ||
| } | ||
|
|
||
| func TestCurrentPeriodStart_Rolling(t *testing.T) { | ||
| now := time.Date(2026, 3, 10, 14, 30, 0, 0, time.UTC) | ||
| start := budget.CurrentPeriodStart(now, "rolling", 15) | ||
| assert.Equal(t, time.Date(2026, 2, 15, 0, 0, 0, 0, time.UTC), start) | ||
|
|
||
| now = time.Date(2026, 3, 20, 14, 30, 0, 0, time.UTC) | ||
| start = budget.CurrentPeriodStart(now, "rolling", 15) | ||
| assert.Equal(t, time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC), start) | ||
|
|
||
| now = time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC) | ||
| start = budget.CurrentPeriodStart(now, "rolling", 15) | ||
| assert.Equal(t, time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC), start) | ||
| } | ||
|
|
||
| func TestCurrentPeriodStart_RollingDay28_February(t *testing.T) { | ||
| now := time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC) | ||
| start := budget.CurrentPeriodStart(now, "rolling", 28) | ||
| assert.Equal(t, time.Date(2026, 2, 28, 0, 0, 0, 0, time.UTC), start) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| func TestChecker_Check_GlobalHardBlock(t *testing.T) { | ||
| checker := &budget.Checker{ | ||
| GlobalMonthlyTokenLimit: 1000, | ||
| ResetMode: "calendar", | ||
| ResetDay: 1, | ||
| GetTotalUsage: func(ctx context.Context, period time.Time) (int64, error) { | ||
| return 1001, nil | ||
| }, | ||
| } | ||
|
|
||
| result := checker.Check(context.Background(), budget.CheckInput{ | ||
| TeamID: "team-1", | ||
| Provider: "openai", | ||
| }) | ||
|
|
||
| assert.True(t, result.Blocked) | ||
| assert.Equal(t, "global", result.BlockedBy) | ||
| assert.Contains(t, result.Message, "global monthly token limit") | ||
| } | ||
|
|
||
| func TestChecker_Check_TeamWarnOnly(t *testing.T) { | ||
| checker := &budget.Checker{ | ||
| ResetMode: "calendar", | ||
| ResetDay: 1, | ||
| GetTotalUsage: func(ctx context.Context, period time.Time) (int64, error) { | ||
| return 0, nil | ||
| }, | ||
| GetProviderUsage: func(ctx context.Context, teamID, provider string, period time.Time) (int64, error) { | ||
| return 5001, nil | ||
| }, | ||
| GetTeamBudget: func(ctx context.Context, teamID, provider string) (*budget.TeamBudget, error) { | ||
| return &budget.TeamBudget{MonthlyTokenLimit: 5000, Enforcement: "warn"}, nil | ||
| }, | ||
| } | ||
|
|
||
| result := checker.Check(context.Background(), budget.CheckInput{ | ||
| TeamID: "team-1", | ||
| Provider: "openai", | ||
| }) | ||
|
|
||
| assert.False(t, result.Blocked) | ||
| assert.True(t, result.Warning) | ||
| assert.Contains(t, result.Message, "team budget exceeded") | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| func TestChecker_Check_AllPass(t *testing.T) { | ||
| checker := &budget.Checker{ | ||
| GlobalMonthlyTokenLimit: 100000, | ||
| ResetMode: "calendar", | ||
| ResetDay: 1, | ||
| GetTotalUsage: func(ctx context.Context, period time.Time) (int64, error) { | ||
| return 500, nil | ||
| }, | ||
| GetProviderUsage: func(ctx context.Context, teamID, provider string, period time.Time) (int64, error) { | ||
| return 200, nil | ||
| }, | ||
| GetTeamBudget: func(ctx context.Context, teamID, provider string) (*budget.TeamBudget, error) { | ||
| return &budget.TeamBudget{MonthlyTokenLimit: 10000, Enforcement: "hard"}, nil | ||
| }, | ||
| } | ||
|
|
||
| result := checker.Check(context.Background(), budget.CheckInput{ | ||
| TeamID: "team-1", | ||
| Provider: "openai", | ||
| }) | ||
|
|
||
| assert.False(t, result.Blocked) | ||
| assert.False(t, result.Warning) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| func TestChecker_Check_FailOpenOnGetTotalUsageError(t *testing.T) { | ||
| checker := &budget.Checker{ | ||
| GlobalMonthlyTokenLimit: 1000, | ||
| ResetMode: "calendar", | ||
| ResetDay: 1, | ||
| GetTotalUsage: func(ctx context.Context, period time.Time) (int64, error) { | ||
| return 0, fmt.Errorf("db connection lost") | ||
| }, | ||
| } | ||
|
|
||
| result := checker.Check(context.Background(), budget.CheckInput{ | ||
| TeamID: "team-1", | ||
| Provider: "openai", | ||
| }) | ||
|
|
||
| // Intentional fail-open: DB errors should not block AI steps. | ||
| assert.False(t, result.Blocked) | ||
| assert.False(t, result.Warning) | ||
| } | ||
|
|
||
| func TestChecker_Check_FailOpenOnGetTeamBudgetError(t *testing.T) { | ||
| checker := &budget.Checker{ | ||
| ResetMode: "calendar", | ||
| ResetDay: 1, | ||
| GetTotalUsage: func(ctx context.Context, period time.Time) (int64, error) { | ||
| return 0, nil | ||
| }, | ||
| GetProviderUsage: func(ctx context.Context, teamID, provider string, period time.Time) (int64, error) { | ||
| return 0, nil | ||
| }, | ||
| GetTeamBudget: func(ctx context.Context, teamID, provider string) (*budget.TeamBudget, error) { | ||
| return nil, fmt.Errorf("db timeout") | ||
| }, | ||
| } | ||
|
|
||
| result := checker.Check(context.Background(), budget.CheckInput{ | ||
| TeamID: "team-1", | ||
| Provider: "openai", | ||
| }) | ||
|
|
||
| assert.False(t, result.Blocked) | ||
| assert.False(t, result.Warning) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| func TestChecker_Check_FailOpenOnGetProviderUsageError(t *testing.T) { | ||
| checker := &budget.Checker{ | ||
| ResetMode: "calendar", | ||
| ResetDay: 1, | ||
| GetTotalUsage: func(ctx context.Context, period time.Time) (int64, error) { | ||
| return 0, nil | ||
| }, | ||
| GetProviderUsage: func(ctx context.Context, teamID, provider string, period time.Time) (int64, error) { | ||
| return 0, fmt.Errorf("provider usage query failed") | ||
| }, | ||
| GetTeamBudget: func(ctx context.Context, teamID, provider string) (*budget.TeamBudget, error) { | ||
| return &budget.TeamBudget{MonthlyTokenLimit: 5000, Enforcement: "hard"}, nil | ||
| }, | ||
| } | ||
|
|
||
| result := checker.Check(context.Background(), budget.CheckInput{ | ||
| TeamID: "team-1", | ||
| Provider: "openai", | ||
| }) | ||
|
|
||
| assert.False(t, result.Blocked) | ||
| assert.False(t, result.Warning) | ||
| } | ||
|
|
||
| func TestChecker_Check_TeamHardBlock(t *testing.T) { | ||
| checker := &budget.Checker{ | ||
| ResetMode: "calendar", | ||
| ResetDay: 1, | ||
| GetTotalUsage: func(ctx context.Context, period time.Time) (int64, error) { | ||
| return 0, nil | ||
| }, | ||
| GetProviderUsage: func(ctx context.Context, teamID, provider string, period time.Time) (int64, error) { | ||
| return 5001, nil | ||
| }, | ||
| GetTeamBudget: func(ctx context.Context, teamID, provider string) (*budget.TeamBudget, error) { | ||
| return &budget.TeamBudget{MonthlyTokenLimit: 5000, Enforcement: "hard"}, nil | ||
| }, | ||
| } | ||
|
|
||
| result := checker.Check(context.Background(), budget.CheckInput{ | ||
| TeamID: "team-1", | ||
| Provider: "openai", | ||
| }) | ||
|
|
||
| assert.True(t, result.Blocked) | ||
| assert.Equal(t, "team", result.BlockedBy) | ||
| assert.Contains(t, result.Message, "team budget exceeded") | ||
| } | ||
|
|
||
| func TestChecker_CheckExecutionBudget(t *testing.T) { | ||
| result := budget.CheckExecutionBudget(50000, 50001) | ||
| assert.True(t, result.Blocked) | ||
| assert.Equal(t, "workflow", result.BlockedBy) | ||
|
|
||
| result = budget.CheckExecutionBudget(50000, 50000) | ||
| assert.True(t, result.Blocked) | ||
| assert.Equal(t, "workflow", result.BlockedBy) | ||
|
|
||
| result = budget.CheckExecutionBudget(50000, 49999) | ||
| assert.False(t, result.Blocked) | ||
|
|
||
| result = budget.CheckExecutionBudget(0, 999999) | ||
| assert.False(t, result.Blocked) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CurrentPeriodStartbuilds a UTC period boundary but usesnow.Day()/Month()/Year()from whatever timezonenowis in. Callers passtime.Now()(local), so around month boundaries this can compute the wrong period for the “UTC” semantics described in docs. Consider normalizingnowto UTC first (e.g.,now = now.UTC()) before extracting year/month/day.