-
-
Notifications
You must be signed in to change notification settings - Fork 50
feat(budget): add user path budget management #276
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 all commits
e9363b6
98eb204
1bacaca
047a2ee
cdff3d2
10efcd5
771d3fc
535db6d
9e1eed1
39aee20
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,14 @@ | ||
| # Changelog | ||
|
|
||
| ## Unreleased | ||
|
|
||
| ### Migration notes | ||
|
|
||
| - Budget defaults are `USAGE_ENABLED=true` and `BUDGETS_ENABLED=true`. | ||
| - Minimal enablement: | ||
|
|
||
| ```bash | ||
| USAGE_ENABLED=true BUDGETS_ENABLED=true ./gomodel | ||
| ``` | ||
|
|
||
| - `internal/app/app.go` disables budgets when it sees `BUDGETS_ENABLED=true` with `USAGE_ENABLED=false`, because budget checks depend on usage data. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,17 +6,20 @@ import ( | |
| "encoding/json" | ||
| "fmt" | ||
| "io" | ||
| "log/slog" | ||
| "math" | ||
| "os" | ||
| "path" | ||
| "reflect" | ||
| "regexp" | ||
| "sort" | ||
| "strconv" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "gopkg.in/yaml.v3" | ||
|
|
||
| "gomodel/internal/core" | ||
| "gomodel/internal/storage" | ||
| ) | ||
|
|
||
|
|
@@ -37,6 +40,7 @@ type Config struct { | |
| Storage StorageConfig `yaml:"storage"` | ||
| Logging LogConfig `yaml:"logging"` | ||
| Usage UsageConfig `yaml:"usage"` | ||
| Budgets BudgetsConfig `yaml:"budgets"` | ||
| Metrics MetricsConfig `yaml:"metrics"` | ||
| HTTP HTTPConfig `yaml:"http"` | ||
| Admin AdminConfig `yaml:"admin"` | ||
|
|
@@ -374,6 +378,37 @@ type UsageConfig struct { | |
| RetentionDays int `yaml:"retention_days" env:"USAGE_RETENTION_DAYS"` | ||
| } | ||
|
|
||
| // BudgetsConfig holds per-user-path spend limits. | ||
| type BudgetsConfig struct { | ||
| // Enabled controls whether budget checks are active. | ||
| // Default: true. Requires usage tracking because spend limits are evaluated | ||
| // from usage cost records. | ||
| Enabled bool `yaml:"enabled" env:"BUDGETS_ENABLED"` | ||
|
|
||
| // UserPaths declares budget limits by tracked user path. | ||
| UserPaths []BudgetUserPathConfig `yaml:"user_paths"` | ||
| } | ||
|
|
||
| // BudgetUserPathConfig declares one or more budget limits for a user path. | ||
| type BudgetUserPathConfig struct { | ||
| Path string `yaml:"path"` | ||
| Limits []BudgetLimitConfig `yaml:"limits"` | ||
| } | ||
|
|
||
| // BudgetLimitConfig declares one spend limit for a reset period. | ||
| type BudgetLimitConfig struct { | ||
| // Period accepts hourly, daily, weekly, or monthly. The resolved period is | ||
| // persisted as PeriodSeconds in the database. | ||
| Period string `yaml:"period"` | ||
|
|
||
| // PeriodSeconds can be set directly instead of Period. Standard values are | ||
| // 3600, 86400, 604800, and 2592000. | ||
| PeriodSeconds int64 `yaml:"period_seconds"` | ||
|
|
||
| // Amount is the maximum allowed tracked provider spend for the period. | ||
| Amount float64 `yaml:"amount"` | ||
| } | ||
|
|
||
| // StorageConfig holds database storage configuration (used by audit logging, usage tracking, future IAM, etc.) | ||
| type StorageConfig struct { | ||
| // Type specifies the storage backend: "sqlite" (default), "postgresql", or "mongodb" | ||
|
|
@@ -1007,6 +1042,9 @@ func buildDefaultConfig() *Config { | |
| FlushInterval: 5, | ||
| RetentionDays: 90, | ||
| }, | ||
| Budgets: BudgetsConfig{ | ||
| Enabled: true, | ||
| }, | ||
| Metrics: MetricsConfig{ | ||
|
Comment on lines
+1046
to
1048
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.
Those users must add |
||
| Endpoint: "/metrics", | ||
| }, | ||
|
|
@@ -1055,6 +1093,13 @@ func Load() (*LoadResult, error) { | |
| if err := applyEnvOverrides(cfg); err != nil { | ||
| return nil, err | ||
| } | ||
| applyBudgetDependencies(cfg) | ||
| if err := applyBudgetEnv(cfg); err != nil { | ||
| return nil, err | ||
| } | ||
| if err := validateBudgetConfig(&cfg.Budgets); err != nil { | ||
| return nil, err | ||
| } | ||
| cfg.Server.BasePath = NormalizeBasePath(cfg.Server.BasePath) | ||
| cfg.Models.ConfiguredProviderModelsMode = ResolveConfiguredProviderModelsMode(cfg.Models.ConfiguredProviderModelsMode) | ||
| if !cfg.Models.ConfiguredProviderModelsMode.Valid() { | ||
|
|
@@ -1256,6 +1301,188 @@ func loadFallbackConfig(cfg *FallbackConfig) error { | |
| return nil | ||
| } | ||
|
|
||
| func applyBudgetEnv(cfg *Config) error { | ||
| if cfg == nil { | ||
| return nil | ||
| } | ||
| if !cfg.Budgets.Enabled { | ||
| return nil | ||
| } | ||
|
|
||
| const prefix = "SET_BUDGET_" | ||
| for _, item := range os.Environ() { | ||
| key, value, ok := strings.Cut(item, "=") | ||
| if !ok || !strings.HasPrefix(key, prefix) || strings.TrimSpace(value) == "" { | ||
| continue | ||
| } | ||
| path := budgetEnvPath(key[len(prefix):]) | ||
| limits, err := parseBudgetEnvLimits(value) | ||
| if err != nil { | ||
| return fmt.Errorf("invalid value for %s: %w", key, err) | ||
| } | ||
| if len(limits) == 0 { | ||
| continue | ||
| } | ||
| entry := BudgetUserPathConfig{ | ||
| Path: path, | ||
| Limits: limits, | ||
| } | ||
| replaced := cfg.Budgets.UserPaths[:0] | ||
| for _, existing := range cfg.Budgets.UserPaths { | ||
| if existing.Path != entry.Path { | ||
| replaced = append(replaced, existing) | ||
| } | ||
| } | ||
| cfg.Budgets.UserPaths = append(replaced, entry) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func budgetEnvPath(suffix string) string { | ||
| suffix = strings.ToLower(strings.TrimSpace(suffix)) | ||
| if suffix == "" { | ||
| return "/" | ||
| } | ||
| segments := make([]string, 0) | ||
| for _, part := range strings.Split(suffix, "__") { | ||
| part = strings.TrimSpace(part) | ||
|
Comment on lines
+1325
to
+1348
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.
|
||
| if part != "" { | ||
| segments = append(segments, part) | ||
| } | ||
| } | ||
| if len(segments) == 0 { | ||
| return "/" | ||
| } | ||
| return "/" + strings.Join(segments, "/") | ||
| } | ||
|
|
||
| func parseBudgetEnvLimits(raw string) ([]BudgetLimitConfig, error) { | ||
| raw = strings.TrimSpace(raw) | ||
| if raw == "" { | ||
| return nil, nil | ||
| } | ||
| if strings.HasPrefix(raw, "{") { | ||
| values := map[string]float64{} | ||
| if err := json.Unmarshal([]byte(raw), &values); err != nil { | ||
| return nil, err | ||
| } | ||
| limits := make([]BudgetLimitConfig, 0, len(values)) | ||
| periods := make([]string, 0, len(values)) | ||
| for period := range values { | ||
| periods = append(periods, period) | ||
| } | ||
| sort.Strings(periods) | ||
| for _, period := range periods { | ||
| limits = append(limits, BudgetLimitConfig{Period: period, Amount: values[period]}) | ||
| } | ||
| return limits, nil | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| if strings.HasPrefix(raw, "[") { | ||
| var limits []BudgetLimitConfig | ||
| if err := json.Unmarshal([]byte(raw), &limits); err != nil { | ||
| return nil, err | ||
| } | ||
| return limits, nil | ||
| } | ||
|
|
||
| fields := strings.FieldsFunc(raw, func(r rune) bool { | ||
| return r == ',' || r == ';' || r == '\n' | ||
| }) | ||
| limits := make([]BudgetLimitConfig, 0, len(fields)) | ||
| for _, field := range fields { | ||
| field = strings.TrimSpace(field) | ||
| if field == "" { | ||
| continue | ||
| } | ||
| period, amountText, ok := strings.Cut(field, "=") | ||
| if !ok { | ||
| period, amountText, ok = strings.Cut(field, ":") | ||
| } | ||
| if !ok { | ||
| return nil, fmt.Errorf("budget limit %q must use period=amount", field) | ||
| } | ||
| amount, err := strconv.ParseFloat(strings.TrimSpace(amountText), 64) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("budget amount %q is not a valid number", amountText) | ||
| } | ||
| limits = append(limits, BudgetLimitConfig{ | ||
| Period: strings.TrimSpace(period), | ||
| Amount: amount, | ||
| }) | ||
| } | ||
| return limits, nil | ||
| } | ||
|
|
||
| func validateBudgetConfig(cfg *BudgetsConfig) error { | ||
| if cfg == nil { | ||
| return nil | ||
| } | ||
| if !cfg.Enabled { | ||
| return nil | ||
| } | ||
| seen := make(map[string]struct{}) | ||
| for pathIdx, entry := range cfg.UserPaths { | ||
| if strings.TrimSpace(entry.Path) == "" { | ||
| return fmt.Errorf("budgets.user_paths[%d].path is required", pathIdx) | ||
| } | ||
| normalizedPath, err := core.NormalizeUserPath(entry.Path) | ||
| if err != nil { | ||
| return fmt.Errorf("budgets.user_paths[%d].path is invalid: %w", pathIdx, err) | ||
| } | ||
| if normalizedPath == "" { | ||
| return fmt.Errorf("budgets.user_paths[%d].path is required", pathIdx) | ||
| } | ||
| cfg.UserPaths[pathIdx].Path = normalizedPath | ||
| for limitIdx, limit := range entry.Limits { | ||
| if math.IsNaN(limit.Amount) || math.IsInf(limit.Amount, 0) || limit.Amount <= 0 { | ||
| return fmt.Errorf("budgets.user_paths[%d].limits[%d].amount must be a finite number greater than 0", pathIdx, limitIdx) | ||
| } | ||
| seconds := limit.PeriodSeconds | ||
| if limit.PeriodSeconds <= 0 { | ||
| parsed, ok := budgetPeriodSeconds(limit.Period) | ||
| if !ok { | ||
| return fmt.Errorf("budgets.user_paths[%d].limits[%d].period must be one of hourly, daily, weekly, monthly or period_seconds must be set", pathIdx, limitIdx) | ||
| } | ||
| seconds = parsed | ||
| cfg.UserPaths[pathIdx].Limits[limitIdx].PeriodSeconds = seconds | ||
| } | ||
| key := normalizedPath + ":" + strconv.FormatInt(seconds, 10) | ||
| if _, ok := seen[key]; ok { | ||
| return fmt.Errorf("duplicate budget for path %s period %d", normalizedPath, seconds) | ||
| } | ||
| seen[key] = struct{}{} | ||
| } | ||
| } | ||
| return nil | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| func applyBudgetDependencies(cfg *Config) { | ||
| if cfg == nil || !cfg.Budgets.Enabled || cfg.Usage.Enabled { | ||
| return | ||
| } | ||
| cfg.Budgets.Enabled = false | ||
| slog.Warn("budget management disabled because usage tracking is disabled", | ||
| "usage_enabled", false, | ||
| "budgets_enabled", false, | ||
| "hint", "enable usage tracking to use budgets, or set BUDGETS_ENABLED=false to silence this warning", | ||
| ) | ||
| } | ||
|
|
||
| func budgetPeriodSeconds(period string) (int64, bool) { | ||
| switch strings.ToLower(strings.TrimSpace(period)) { | ||
| case "hour", "hourly", "hours": | ||
| return 3600, true | ||
| case "day", "daily", "days": | ||
| return 86400, true | ||
| case "week", "weekly", "weeks": | ||
| return 604800, true | ||
| case "month", "monthly", "months": | ||
| return 2592000, true | ||
| default: | ||
| return 0, false | ||
| } | ||
| } | ||
|
|
||
| // applyEnvOverrides walks cfg's struct fields and applies env var overrides | ||
| // based on `env` struct tags. Maps are skipped. | ||
| func applyEnvOverrides(cfg *Config) error { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.