Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
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
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- `internal/app/app.go` disables budgets when it sees `BUDGETS_ENABLED=true` with `USAGE_ENABLED=false`, because budget checks depend on usage data.
12 changes: 12 additions & 0 deletions config/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ usage:
flush_interval: 5
retention_days: 90

budgets:
enabled: true # env: BUDGETS_ENABLED; with no configured budgets this has no effect
user_paths:
# Env equivalent:
# SET_BUDGET_USER__PATH__EXAMPLE="daily=10,weekly=50"
- path: "/user/path/example"
limits:
- period: "daily" # hourly, daily, weekly, monthly; stored in DB as period_seconds
amount: 10.00
- period: "weekly"
amount: 50.00

metrics:
enabled: false
endpoint: "/metrics"
Expand Down
227 changes: 227 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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"`
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -1007,6 +1042,9 @@ func buildDefaultConfig() *Config {
FlushInterval: 5,
RetentionDays: 90,
},
Budgets: BudgetsConfig{
Enabled: true,
},
Metrics: MetricsConfig{
Comment on lines +1046 to 1048
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Breaking change for deployments with usage disabled

BudgetsConfig.Enabled defaults to true. Any existing deployment that has explicitly disabled usage tracking (USAGE_ENABLED=false or usage.enabled: false) will fail to start after upgrading with:

"budgets require usage tracking to be enabled because spend limits are evaluated from usage cost records"

Those users must add BUDGETS_ENABLED=false (or budgets.enabled: false) to restore previous behavior. The failure is silent from the outside — the process exits on startup — and the fix is not obvious without reading the new config docs.

Endpoint: "/metrics",
},
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Underscore path-separator ambiguity in SET_BUDGET_* env vars

budgetEnvPath splits the suffix on _ to build the user-path, so SET_BUDGET_TEAM_A produces /team/a, not /team_a. There is no way to target a single path segment that itself contains an underscore (e.g. user_123). Since user paths are freely defined by admins, this silent mis-mapping could configure budgets against the wrong path tree without any error. Consider documenting this constraint explicitly, or choosing a different delimiter (e.g. double-underscore __ as separator vs single underscore in a segment).

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
}
Comment thread
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
Comment thread
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 {
Expand Down
Loading
Loading