diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..0f7b8087 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/config/config.example.yaml b/config/config.example.yaml index 79ba4908..bb3ff27c 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -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" diff --git a/config/config.go b/config/config.go index 54451145..a538807c 100644 --- a/config/config.go +++ b/config/config.go @@ -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{ 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) + 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 + } + 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 +} + +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 { diff --git a/config/config_test.go b/config/config_test.go index 897bec44..dfaf772f 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -55,6 +55,7 @@ func clearAllConfigEnvVars(t *testing.T) { "LOGGING_FLUSH_INTERVAL", "LOGGING_RETENTION_DAYS", "USAGE_ENABLED", "ENFORCE_RETURNING_USAGE_DATA", "USAGE_BUFFER_SIZE", "USAGE_FLUSH_INTERVAL", "USAGE_RETENTION_DAYS", + "BUDGETS_ENABLED", "GUARDRAILS_ENABLED", "ENABLE_GUARDRAILS_FOR_BATCH_PROCESSING", "FEATURE_FALLBACK_MODE", "FALLBACK_MANUAL_RULES_PATH", "MODEL_OVERRIDES_ENABLED", "MODELS_ENABLED_BY_DEFAULT", "KEEP_ONLY_ALIASES_AT_MODELS_ENDPOINT", "CONFIGURED_PROVIDER_MODELS_MODE", @@ -64,6 +65,13 @@ func clearAllConfigEnvVars(t *testing.T) { t.Setenv(key, "") os.Unsetenv(key) } + for _, item := range os.Environ() { + key, _, _ := strings.Cut(item, "=") + if strings.HasPrefix(key, "SET_BUDGET_") { + t.Setenv(key, "") + os.Unsetenv(key) + } + } clearProviderEnvVars(t) } @@ -163,6 +171,9 @@ func TestBuildDefaultConfig(t *testing.T) { if cfg.Usage.RetentionDays != 90 { t.Errorf("expected Usage.RetentionDays=90, got %d", cfg.Usage.RetentionDays) } + if !cfg.Budgets.Enabled { + t.Error("expected Budgets.Enabled=true") + } if cfg.Metrics.Endpoint != "/metrics" { t.Errorf("expected Metrics.Endpoint=/metrics, got %s", cfg.Metrics.Endpoint) } @@ -211,6 +222,227 @@ func TestBuildDefaultConfig(t *testing.T) { } } +func TestLoadBudgetEnvUserPath(t *testing.T) { + clearAllConfigEnvVars(t) + + withTempDir(t, func(string) { + t.Setenv("SET_BUDGET_USER__PATH__EXAMPLE", "daily=12.5,weekly=50") + + result, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + + entries := result.Config.Budgets.UserPaths + if len(entries) != 1 { + t.Fatalf("expected 1 budget user path, got %d", len(entries)) + } + if got, want := entries[0].Path, "/user/path/example"; got != want { + t.Fatalf("budget env path = %q, want %q", got, want) + } + if len(entries[0].Limits) != 2 { + t.Fatalf("expected 2 budget limits, got %d", len(entries[0].Limits)) + } + if got, want := entries[0].Limits[0].PeriodSeconds, int64(86400); got != want { + t.Fatalf("daily period seconds = %d, want %d", got, want) + } + if got, want := entries[0].Limits[0].Amount, 12.5; got != want { + t.Fatalf("daily amount = %v, want %v", got, want) + } + if got, want := entries[0].Limits[1].PeriodSeconds, int64(604800); got != want { + t.Fatalf("weekly period seconds = %d, want %d", got, want) + } + if got, want := entries[0].Limits[1].Amount, 50.0; got != want { + t.Fatalf("weekly amount = %v, want %v", got, want) + } + }) +} + +func TestBudgetEnvPathUsesDoubleUnderscoreSeparator(t *testing.T) { + tests := []struct { + name string + suffix string + want string + }{ + {name: "root", suffix: "", want: "/"}, + {name: "double underscore separator", suffix: "TEAM__ALPHA", want: "/team/alpha"}, + {name: "single underscore preserved", suffix: "USER_123", want: "/user_123"}, + {name: "single underscores preserved per segment", suffix: "USER_123__PROJECT_A", want: "/user_123/project_a"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := budgetEnvPath(tt.suffix); got != tt.want { + t.Fatalf("budgetEnvPath(%q) = %q, want %q", tt.suffix, got, tt.want) + } + }) + } +} + +func TestLoadBudgetEnvJSONLimitsAreSorted(t *testing.T) { + clearAllConfigEnvVars(t) + + withTempDir(t, func(string) { + t.Setenv("SET_BUDGET_TEAM__ALPHA", `{"weekly":50,"daily":10,"monthly":100}`) + + result, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + + limits := result.Config.Budgets.UserPaths[0].Limits + got := []string{limits[0].Period, limits[1].Period, limits[2].Period} + want := []string{"daily", "monthly", "weekly"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("period order = %v, want %v", got, want) + } + }) +} + +func TestLoadBudgetEnvReplacesMatchingYAMLUserPath(t *testing.T) { + clearAllConfigEnvVars(t) + + withTempDir(t, func(dir string) { + yamlConfig := ` +budgets: + user_paths: + - path: /team/alpha + limits: + - period: daily + amount: 1 + - path: /team/beta + limits: + - period: daily + amount: 2 +` + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yamlConfig), 0644); err != nil { + t.Fatalf("Failed to write config.yaml: %v", err) + } + t.Setenv("SET_BUDGET_TEAM__ALPHA", "weekly=50") + + result, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + + entries := result.Config.Budgets.UserPaths + if len(entries) != 2 { + t.Fatalf("expected 2 budget user paths, got %d: %+v", len(entries), entries) + } + if entries[0].Path != "/team/beta" || entries[0].Limits[0].Amount != 2 { + t.Fatalf("first budget entry = %+v, want untouched /team/beta YAML entry", entries[0]) + } + if entries[1].Path != "/team/alpha" { + t.Fatalf("env replacement path = %q, want /team/alpha", entries[1].Path) + } + if len(entries[1].Limits) != 1 || entries[1].Limits[0].PeriodSeconds != 604800 || entries[1].Limits[0].Amount != 50 { + t.Fatalf("env replacement limits = %+v, want weekly=50", entries[1].Limits) + } + }) +} + +func TestLoadBudgetEnvRejectsNonFiniteAmount(t *testing.T) { + clearAllConfigEnvVars(t) + + withTempDir(t, func(string) { + t.Setenv("SET_BUDGET_TEAM__ALPHA", "daily=NaN") + + _, err := Load() + if err == nil { + t.Fatal("Load() error = nil, want non-finite budget amount error") + } + if !strings.Contains(err.Error(), "amount must be a finite number greater than 0") { + t.Fatalf("Load() error = %v, want finite amount validation", err) + } + }) +} + +func TestLoadBudgetConfigRejectsDuplicateLogicalBudgets(t *testing.T) { + clearAllConfigEnvVars(t) + + withTempDir(t, func(dir string) { + yamlConfig := ` +budgets: + user_paths: + - path: team/alpha + limits: + - period: daily + amount: 1 + - path: /team/alpha + limits: + - period_seconds: 86400 + amount: 2 +` + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yamlConfig), 0644); err != nil { + t.Fatalf("Failed to write config.yaml: %v", err) + } + + _, err := Load() + if err == nil { + t.Fatal("Load() error = nil, want duplicate budget error") + } + if !strings.Contains(err.Error(), "duplicate budget for path /team/alpha period 86400") { + t.Fatalf("Load() error = %v, want duplicate budget validation", err) + } + }) +} + +func TestLoadBudgetEnvDisablesBudgetsWhenUsageTrackingDisabled(t *testing.T) { + clearAllConfigEnvVars(t) + + withTempDir(t, func(string) { + t.Setenv("USAGE_ENABLED", "false") + t.Setenv("SET_BUDGET_USER__PATH__EXAMPLE", "daily=12.5") + + result, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + if result.Config.Budgets.Enabled { + t.Fatal("expected budgets to be disabled when usage tracking is disabled") + } + if len(result.Config.Budgets.UserPaths) != 0 { + t.Fatalf("expected auto-disabled budgets to ignore env user paths, got %d", len(result.Config.Budgets.UserPaths)) + } + }) +} + +func TestLoadBudgetsEnabledDisablesBudgetsWhenUsageTrackingDisabledWithoutSeedBudgets(t *testing.T) { + clearAllConfigEnvVars(t) + + withTempDir(t, func(string) { + t.Setenv("USAGE_ENABLED", "false") + + result, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + if result.Config.Budgets.Enabled { + t.Fatal("expected budgets to be disabled when usage tracking is disabled") + } + }) +} + +func TestLoadDisabledBudgetsIgnoreMalformedBudgetEnv(t *testing.T) { + clearAllConfigEnvVars(t) + + withTempDir(t, func(string) { + t.Setenv("BUDGETS_ENABLED", "false") + t.Setenv("SET_BUDGET_USER__PATH__EXAMPLE", "not-a-budget-limit") + + result, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + if result.Config.Budgets.Enabled { + t.Fatal("expected budgets to be disabled") + } + if len(result.Config.Budgets.UserPaths) != 0 { + t.Fatalf("expected disabled budgets to ignore env user paths, got %d", len(result.Config.Budgets.UserPaths)) + } + }) +} + func TestLoad_ZeroConfig(t *testing.T) { clearAllConfigEnvVars(t) diff --git a/docs/advanced/admin-endpoints.mdx b/docs/advanced/admin-endpoints.mdx index d15f7a13..1f2edd0a 100644 --- a/docs/advanced/admin-endpoints.mdx +++ b/docs/advanced/admin-endpoints.mdx @@ -1,6 +1,6 @@ --- title: "Admin Endpoints" -description: "Built-in REST API and dashboard for monitoring usage, models, and gateway health." +description: "Built-in REST API and dashboard for monitoring usage, budgets, models, and gateway health." icon: "server-cog" --- @@ -10,7 +10,7 @@ GoModel ships with admin endpoints **enabled by default**. The goal is simple: y The admin layer is split into two independently controllable pieces: -1. **Admin REST API** (`/admin/api/v1/*`) — machine-readable JSON endpoints for usage data and model inventory. Protected by `GOMODEL_MASTER_KEY` like all other API routes. +1. **Admin REST API** (`/admin/api/v1/*`) — machine-readable JSON endpoints for usage data, budgets, and model inventory. Protected by `GOMODEL_MASTER_KEY` like all other API routes. 2. **Admin Dashboard UI** (`/admin/dashboard`) — a lightweight, embedded HTML dashboard that visualizes the same data. No external dependencies, no JavaScript frameworks to install — it's compiled into the binary. Both are on by default because observability shouldn't be opt-in. If you don't need them, turn them off with a single environment variable. @@ -122,6 +122,29 @@ The `date` field in the response changes format based on the interval: `YYYY-MM- Returns an empty array if usage tracking is disabled or no data exists for the period. +### Budget endpoints + +Budgets are managed under `/admin/api/v1/budgets`. These endpoints are +available when budget management is enabled. + +| Method | Path | Description | +| -------- | ------------------------------------- | ------------------------------------ | +| `GET` | `/admin/api/v1/budgets` | List budgets with current status | +| `PUT` | `/admin/api/v1/budgets/{user_path}/{period}` | Create or update one budget | +| `DELETE` | `/admin/api/v1/budgets/{user_path}/{period}` | Delete one budget | +| `GET` | `/admin/api/v1/budgets/settings` | Read budget reset settings | +| `PUT` | `/admin/api/v1/budgets/settings` | Update budget reset settings | +| `POST` | `/admin/api/v1/budgets/reset-one` | Reset one budget period | +| `POST` | `/admin/api/v1/budgets/reset` | Reset all budget periods | + +`PUT`, `DELETE`, and `reset-one` identify one budget by the composite +`(user_path, period)` key, or `(user_path, period_seconds)` for custom periods. +These operations are not global per period. For the path-scoped `PUT` and +`DELETE` routes, URL-encode `user_path`; see [Budgets](/features/budgets) for +the request shape. + +See [Budgets](/features/budgets) for request examples and enforcement behavior. + ### GET /admin/api/v1/models Returns all registered models with both provider type and configured provider name. diff --git a/docs/advanced/config-yaml.mdx b/docs/advanced/config-yaml.mdx index 67385cd5..8fb05be9 100644 --- a/docs/advanced/config-yaml.mdx +++ b/docs/advanced/config-yaml.mdx @@ -15,6 +15,7 @@ especially: - custom provider instance names that do not fit the generated `-` env naming - richer reviewable provider model lists, especially when using allowlist mode +- reviewable budget limits by `user_path` - larger nested config that is easier to review in one file For multiple provider instances, env vars support diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index 266a7c2b..fb03a177 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -113,6 +113,37 @@ Storage is shared by audit logging, usage tracking, and future features like IAM | `USAGE_FLUSH_INTERVAL` | Flush interval in seconds | `5` | | `USAGE_RETENTION_DAYS` | Auto-delete after N days (0 = forever) | `90` | +#### Budgets + +Budgets use tracked usage cost records. If usage tracking is disabled, GoModel +starts with budget management disabled and logs a warning. + +| Variable | Description | Default | +| ------------------- | ------------------------------------------------------- | ------- | +| `BUDGETS_ENABLED` | Enable budget management and workflow budget checks when usage tracking is enabled | `true` | +| `SET_BUDGET_` | Seed budget limits for a user path, such as `daily=10` | _(empty)_ | + +`SET_BUDGET_` supports the standard periods `hourly`, `daily`, `weekly`, +and `monthly`. The `` suffix is lowercased; use double underscores +(`__`) between path segments, while single underscores stay inside a segment. +For example, `SET_BUDGET_TEAM__ALPHA__SERVICE="daily=10"` configures +`/team/alpha/service`, and `SET_BUDGET_TEAM_ALPHA="daily=10"` configures +`/team_alpha`. This differs from provider `` variables below, which +convert underscores to hyphens in provider names. + +`SET_BUDGET_="monthly=500"` means a literal environment variable named +`SET_BUDGET_`, which configures the root path `/`. POSIX permits that name, but +some shells and orchestrators, including some Kubernetes validators, may reject +it. Use YAML or the dashboard when your environment cannot set it. + +Migration note: budget management depends on usage tracking. If +`USAGE_ENABLED=false`, GoModel starts with budgets disabled and logs a warning, +even when `BUDGETS_ENABLED=true`. Set both `USAGE_ENABLED=true` and +`BUDGETS_ENABLED=true` to enforce budgets. + +See [Budgets](/features/budgets) for YAML examples, periods, matching, and +workflow enforcement. + #### Metrics @@ -225,6 +256,16 @@ cache: redis: url: "redis://my-redis:6379" +budgets: + enabled: true + user_paths: + - path: "/team/alpha" + limits: + - period: "daily" + amount: 10.00 + - period: "weekly" + amount: 50.00 + providers: openai: type: openai diff --git a/docs/advanced/workflows.mdx b/docs/advanced/workflows.mdx index d3ae13b6..f374252a 100644 --- a/docs/advanced/workflows.mdx +++ b/docs/advanced/workflows.mdx @@ -11,6 +11,7 @@ Workflows are immutable workflow-policy versions stored in the gateway and match They currently control gateway-owned behavior such as: - cache +- budgets - audit logging - usage tracking - guardrails @@ -108,6 +109,7 @@ curl -X POST http://localhost:8080/admin/api/v1/workflows \ "schema_version": 1, "features": { "cache": false, + "budget": true, "audit": true, "usage": true, "guardrails": false, @@ -123,3 +125,4 @@ curl -X POST http://localhost:8080/admin/api/v1/workflows \ - `scope_model` requires `scope_provider_name` - `scope_user_path` is normalized to canonical slash form - managed API keys can override the request `X-GoModel-User-Path`; workflow matching uses the effective request user path +- budget enforcement runs only when the global budget feature and the matched workflow's `budget` feature are both enabled; see [Budgets](/features/budgets) diff --git a/docs/docs.json b/docs/docs.json index 0e000b39..c681c215 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -48,6 +48,7 @@ "features/aliases", "features/user-path", "features/passthrough-api", + "features/budgets", "features/cache", "features/failover" ] diff --git a/docs/features/budgets.mdx b/docs/features/budgets.mdx new file mode 100644 index 00000000..23e000de --- /dev/null +++ b/docs/features/budgets.mdx @@ -0,0 +1,206 @@ +--- +title: "Budgets" +description: "Set spend limits per user path and enforce them through workflow budget controls." +icon: "wallet" +keywords: ["budgets", "spend limits", "cost controls", "user path"] +--- + +## Overview + +Budgets let you set spend limits for a `user_path` subtree. GoModel evaluates +them from tracked usage cost records and blocks matching requests when a limit +has already been spent. + +Use budgets when you want limits such as: + +- `/team/alpha` can spend `$10` per day +- `/team/alpha` can spend `$50` per week +- `/` has a global monthly limit + + + Budget enforcement runs only when budgets are globally enabled and the active + workflow has Budget enabled. The Budget workflow control is enabled by default + when the global budget feature is on. + + +## Enable budgets + +Budgets are enabled by default: + +```env +BUDGETS_ENABLED=true +``` + +Budgets depend on usage tracking because spend is calculated from usage cost +records: + +```env +USAGE_ENABLED=true +``` + +If usage tracking is disabled, GoModel starts with budget management disabled +and logs a warning. + +## Create budgets + +You can create budgets in the dashboard: + +```text +Budgets -> Create Budget +``` + +Dashboard-created budgets are marked as `manual`. Budgets loaded from YAML or +environment variables are marked as `config`. + +You can also seed budgets from YAML: + +```yaml +budgets: + enabled: true + user_paths: + - path: "/team/alpha" + limits: + - period: "daily" + amount: 10.00 + - period: "weekly" + amount: 50.00 + - path: "/" + limits: + - period: "monthly" + amount: 500.00 +``` + +Or use environment variables: + +```env +SET_BUDGET_TEAM__ALPHA="daily=10,weekly=50" +SET_BUDGET_="monthly=500" +``` + +The suffix after `SET_BUDGET_` becomes a user path. Use double underscores +(`__`) between path segments; single underscores remain part of a segment: + +- `SET_BUDGET_TEAM__ALPHA` -> `/team/alpha` +- `SET_BUDGET_USER_123` -> `/user_123` +- `SET_BUDGET_` -> `/` + +There is no escape for a literal double underscore inside a path segment. Use +YAML or the dashboard for paths that need a `__` segment value. + +Supported standard periods are: + +| Period | Seconds | +| --------- | --------- | +| `hourly` | `3600` | +| `daily` | `86400` | +| `weekly` | `604800` | +| `monthly` | `2592000` | + +The Seconds column is the internal period identifier. Standard periods use the +configured reset-anchor logic; for example, `monthly` is stored as `2592000` but +resets on calendar month anchors and clamps the reset day to shorter months. +Only custom `period_seconds` values behave as fixed-second windows. + +For custom windows, set `period_seconds` in YAML: + +```yaml +budgets: + user_paths: + - path: "/team/alpha" + limits: + - period_seconds: 7200 + amount: 5.00 +``` + +## How matching works + +Budget paths apply to the configured path and its descendants: + +- budget path `/team` +- request path `/team/app` +- result: budget applies + +Sibling paths do not match: + +- budget path `/team` +- request path `/team-alpha` +- result: budget does not apply + +If multiple budgets match a request, GoModel checks all matching limits. The +request is rejected if any matching limit is exhausted. + +## Workflow enforcement + +The active workflow controls whether budget checks run for a request. + +In a workflow payload: + +```json +{ + "schema_version": 1, + "features": { + "budget": true, + "cache": true, + "audit": true, + "usage": true, + "guardrails": true, + "fallback": true + } +} +``` + +Use scoped workflows to turn budget enforcement on or off for a provider, model, +or `user_path` subtree. See [Workflows](/advanced/workflows) for matching +precedence. + + + Response cache hits can return before budget enforcement. If a cached response + exists, GoModel can serve it without spending additional provider cost. + + +## Reset windows + +Budget reset anchors are configured in the dashboard under: + +```text +Settings -> Budget Resets +``` + +The reset settings are evaluated in UTC. + +For monthly budgets, if the configured day does not exist in a month, the reset +runs on the last day of that month. For example, a monthly reset day of `31` +runs on April 30 and on February 28 or 29. + +Editing a budget changes the limit but does not reset the current period. Use +the row-level Reset action to start a new period for one budget, or +`Settings -> Reset All Budgets` to reset all budgets. + +## Admin API + +Budget management is also available through the admin API: + +```bash +curl -H "Authorization: Bearer $GOMODEL_MASTER_KEY" \ + http://localhost:8080/admin/api/v1/budgets +``` + +Create or update a budget: + +```bash +curl -X PUT http://localhost:8080/admin/api/v1/budgets/%2Fteam%2Falpha/daily \ + -H "Authorization: Bearer $GOMODEL_MASTER_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "amount": 10.00 + }' +``` + +Delete a budget: + +```bash +curl -X DELETE http://localhost:8080/admin/api/v1/budgets/%2Fteam%2Falpha/daily \ + -H "Authorization: Bearer $GOMODEL_MASTER_KEY" +``` + +See [Admin Endpoints](/advanced/admin-endpoints) for the endpoint list. diff --git a/docs/features/user-path.mdx b/docs/features/user-path.mdx index 5a91fd18..0d950c46 100644 --- a/docs/features/user-path.mdx +++ b/docs/features/user-path.mdx @@ -9,8 +9,8 @@ icon: "route" `user_path` is a normalized hierarchy for the caller, for example `/team/alpha` or `/team/alpha/service`. -GoModel uses it to keep model access, workflows, usage, and audit data scoped to -the right team, tenant, service, or customer. +GoModel uses it to keep model access, workflows, budgets, usage, and audit data +scoped to the right team, tenant, service, or customer. ## API keys @@ -63,7 +63,7 @@ different model access rules. ## Workflows Workflows can also include `scope_user_path`, so different teams or services can -use different cache, audit, usage, guardrail, and fallback settings. +use different budget, cache, audit, usage, guardrail, and fallback settings. You can combine user path with provider and model scope, for example: @@ -72,3 +72,11 @@ You can combine user path with provider and model scope, for example: - `openai_primary` + `gpt-5` + `/team/alpha` See [Workflows](/advanced/workflows) for the full matching order. + +## Budgets + +Budgets are also scoped by `user_path`. A budget for `/team/alpha` applies to +`/team/alpha` and descendants such as `/team/alpha/service`, but not to sibling +paths such as `/team-alpha`. + +See [Budgets](/features/budgets) for spend limits and workflow enforcement. diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx index c7f14430..377918de 100644 --- a/docs/getting-started/quickstart.mdx +++ b/docs/getting-started/quickstart.mdx @@ -120,6 +120,7 @@ Use one of those model IDs in your requests. ## Next Steps - Understand response caching: [Cache](/features/cache) +- Add spend limits: [Budgets](/features/budgets) - Configure production settings: [Configuration](/advanced/configuration) - Add request policies: [Guardrails](/advanced/guardrails) - Connect OpenClaw: [Using GoModel with OpenClaw](/guides/openclaw) diff --git a/docs/openapi.json b/docs/openapi.json index 507a5bc5..023f0a92 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1,7 +1,7 @@ { "openapi": "3.0.0", "info": { - "description": "High-performance AI gateway routing requests to multiple LLM providers (OpenAI, Anthropic, Gemini, Groq, OpenRouter, Z.ai, xAI, Oracle, Ollama). Drop-in OpenAI-compatible API.", + "description": "High-performance AI gateway routing requests to multiple LLM providers (OpenAI, Anthropic, Gemini, Groq, OpenRouter, Z.ai, xAI, MiniMax, Oracle, Ollama). Drop-in OpenAI-compatible API.", "title": "GoModel API", "contact": {}, "version": "1.0" @@ -197,7 +197,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/auditlog.LogListResult" + "$ref": "#/components/schemas/admin.auditLogListResponse" } } } @@ -230,6 +230,451 @@ ] } }, + "/admin/api/v1/budgets": { + "get": { + "tags": [ + "admin" + ], + "summary": "List budgets with current status", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/admin.budgetListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/admin/api/v1/budgets/{user_path}/{period}": { + "put": { + "tags": [ + "admin" + ], + "summary": "Create or update one budget", + "parameters": [ + { + "description": "URL-encoded budget user path", + "name": "user_path", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Budget period name or seconds", + "name": "period", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/admin.upsertBudgetRequest" + } + } + }, + "description": "Budget amount", + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/admin.budgetListResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "admin" + ], + "summary": "Delete one budget", + "parameters": [ + { + "description": "URL-encoded budget user path", + "name": "user_path", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Budget period name or seconds", + "name": "period", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/admin.budgetListResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/admin/api/v1/budgets/reset": { + "post": { + "tags": [ + "admin" + ], + "summary": "Reset all budget periods", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/admin.resetBudgetsRequest" + } + } + }, + "description": "Reset confirmation", + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/admin.resetBudgetsResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/admin/api/v1/budgets/reset-one": { + "post": { + "tags": [ + "admin" + ], + "summary": "Reset one budget period", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/admin.resetBudgetRequest" + } + } + }, + "description": "Budget key", + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/admin.budgetListResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/admin/api/v1/budgets/settings": { + "get": { + "tags": [ + "admin" + ], + "summary": "Get budget reset settings", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/budget.Settings" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + }, + "put": { + "tags": [ + "admin" + ], + "summary": "Update budget reset settings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/admin.updateBudgetSettingsRequest" + } + } + }, + "description": "Budget reset settings", + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/budget.Settings" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, "/admin/api/v1/cache/overview": { "get": { "tags": [ @@ -3552,15 +3997,107 @@ "name": "include_obfuscation", "in": "query", "schema": { - "type": "boolean" + "type": "boolean" + } + }, + { + "description": "Input item offset for providers that support it", + "name": "starting_after", + "in": "query", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.ResponsesResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" + } + } + } + }, + "501": { + "description": "Not Implemented", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" + } + } + } + }, + "502": { + "description": "Bad Gateway", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "responses" + ], + "summary": "Delete a response", + "parameters": [ + { + "description": "Response ID", + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" } }, { - "description": "Input item offset for providers that support it", - "name": "starting_after", + "description": "Provider override for native deletion", + "name": "provider", "in": "query", "schema": { - "type": "integer" + "type": "string" } } ], @@ -3570,7 +4107,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.ResponsesResponse" + "$ref": "#/components/schemas/core.ResponseDeleteResponse" } } } @@ -3631,12 +4168,14 @@ "BearerAuth": [] } ] - }, - "delete": { + } + }, + "/v1/responses/{id}/cancel": { + "post": { "tags": [ "responses" ], - "summary": "Delete a response", + "summary": "Cancel a response", "parameters": [ { "description": "Response ID", @@ -3648,7 +4187,7 @@ } }, { - "description": "Provider override for native deletion", + "description": "Provider override for native cancellation", "name": "provider", "in": "query", "schema": { @@ -3662,7 +4201,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.ResponseDeleteResponse" + "$ref": "#/components/schemas/core.ResponsesResponse" } } } @@ -3725,12 +4264,12 @@ ] } }, - "/v1/responses/{id}/cancel": { - "post": { + "/v1/responses/{id}/input_items": { + "get": { "tags": [ "responses" ], - "summary": "Cancel a response", + "summary": "List response input items", "parameters": [ { "description": "Response ID", @@ -3742,12 +4281,64 @@ } }, { - "description": "Provider override for native cancellation", + "description": "Provider override for native lookups", "name": "provider", "in": "query", "schema": { "type": "string" } + }, + { + "description": "Pagination cursor", + "name": "after", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "description": "Fields to include in listed input items", + "name": "include", + "in": "query", + "explode": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Fields to include in listed input items", + "name": "include[]", + "in": "query", + "explode": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Maximum items to return (1-100, default 20)", + "name": "limit", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "description": "Sort order: asc or desc", + "name": "order", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } } ], "responses": { @@ -3756,7 +4347,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.ResponsesResponse" + "$ref": "#/components/schemas/core.ResponseInputItemListResponse" } } } @@ -3818,173 +4409,280 @@ } ] } + } + }, + "servers": [ + { + "url": "https://gomodel.example.com", + "description": "GoModel HTTPS deployment" }, - "/v1/responses/{id}/input_items": { - "get": { - "tags": [ - "responses" - ], - "summary": "List response input items", - "parameters": [ - { - "description": "Response ID", - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" + { + "url": "http://localhost:8080", + "description": "Local GoModel" + } + ], + "components": { + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "schemas": { + "admin.auditLogEntryResponse": { + "type": "object", + "properties": { + "alias_used": { + "type": "boolean" + }, + "auth_key_id": { + "type": "string" + }, + "auth_method": { + "type": "string" + }, + "cache_type": { + "type": "string" + }, + "client_ip": { + "type": "string" + }, + "data": { + "description": "Data contains flexible request/response information as JSON", + "allOf": [ + { + "$ref": "#/components/schemas/auditlog.LogData" + } + ] + }, + "duration_ns": { + "description": "DurationNs is the request duration in nanoseconds", + "type": "integer" + }, + "error_type": { + "type": "string" + }, + "id": { + "description": "ID is a unique identifier for this log entry (UUID)", + "type": "string" + }, + "method": { + "type": "string" + }, + "path": { + "type": "string" + }, + "provider": { + "description": "canonical provider type used for routing and filters", + "type": "string" + }, + "provider_name": { + "type": "string" + }, + "request_id": { + "description": "Extracted fields for efficient filtering (indexed in relational DBs)", + "type": "string" + }, + "requested_model": { + "description": "Core fields (indexed for queries)", + "type": "string" + }, + "resolved_model": { + "type": "string" + }, + "status_code": { + "type": "integer" + }, + "stream": { + "type": "boolean" + }, + "timestamp": { + "description": "Timestamp is when the request started", + "type": "string" + }, + "usage": { + "$ref": "#/components/schemas/usage.RequestUsageSummary" + }, + "user_path": { + "type": "string" + }, + "workflow_version_id": { + "type": "string" + } + } + }, + "admin.auditLogListResponse": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/admin.auditLogEntryResponse" } }, - { - "description": "Provider override for native lookups", - "name": "provider", - "in": "query", - "schema": { - "type": "string" + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "admin.budgetListResponse": { + "type": "object", + "properties": { + "budgets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/admin.budgetStatusResponse" } }, - { - "description": "Pagination cursor", - "name": "after", - "in": "query", - "schema": { - "type": "string" - } + "server_time": { + "type": "string" + } + } + }, + "admin.budgetStatusResponse": { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "created_at": { + "type": "string" + }, + "has_usage": { + "type": "boolean" + }, + "last_reset_at": { + "type": "string" + }, + "period_end": { + "type": "string" + }, + "period_label": { + "type": "string" + }, + "period_ratio": { + "type": "number" + }, + "period_seconds": { + "type": "integer" + }, + "period_start": { + "type": "string" + }, + "remaining": { + "type": "number" + }, + "source": { + "type": "string" + }, + "spent": { + "type": "number" + }, + "updated_at": { + "type": "string" + }, + "usage_ratio": { + "type": "number" + }, + "user_path": { + "type": "string" + } + } + }, + "admin.deleteBudgetRequest": { + "type": "object", + "properties": { + "period": { + "type": "string" }, - { - "description": "Fields to include in listed input items", - "name": "include", - "in": "query", - "explode": true, - "schema": { - "type": "array", - "items": { - "type": "string" - } - } + "period_seconds": { + "type": "integer" }, - { - "description": "Fields to include in listed input items", - "name": "include[]", - "in": "query", - "explode": true, - "schema": { - "type": "array", - "items": { - "type": "string" - } - } + "user_path": { + "type": "string" + } + } + }, + "admin.resetBudgetRequest": { + "type": "object", + "properties": { + "period": { + "type": "string" }, - { - "description": "Maximum items to return (1-100, default 20)", - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } + "period_seconds": { + "type": "integer" }, - { - "description": "Sort order: asc or desc", - "name": "order", - "in": "query", - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ] - } + "user_path": { + "type": "string" } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/core.ResponseInputItemListResponse" - } - } - } + } + }, + "admin.resetBudgetsRequest": { + "type": "object", + "properties": { + "confirm": { + "type": "string" }, - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" - } - } - } + "confirmation": { + "type": "string" + } + } + }, + "admin.resetBudgetsResponse": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + }, + "admin.updateBudgetSettingsRequest": { + "type": "object", + "properties": { + "daily_reset_hour": { + "type": "integer" }, - "401": { - "description": "Unauthorized", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" - } - } - } + "daily_reset_minute": { + "type": "integer" }, - "404": { - "description": "Not Found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" - } - } - } + "monthly_reset_day": { + "type": "integer" }, - "501": { - "description": "Not Implemented", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" - } - } - } + "monthly_reset_hour": { + "type": "integer" }, - "502": { - "description": "Bad Gateway", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" - } - } - } + "monthly_reset_minute": { + "type": "integer" + }, + "weekly_reset_hour": { + "type": "integer" + }, + "weekly_reset_minute": { + "type": "integer" + }, + "weekly_reset_weekday": { + "type": "integer" } - }, - "security": [ - { - "BearerAuth": [] + } + }, + "admin.upsertBudgetRequest": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "number" } - ] - } - } - }, - "servers": [ - { - "url": "https://gomodel.example.com", - "description": "GoModel HTTPS deployment" - }, - { - "url": "http://localhost:8080", - "description": "Local GoModel" - } - ], - "components": { - "securitySchemes": { - "BearerAuth": { - "type": "apiKey", - "name": "Authorization", - "in": "header" - } - }, - "schemas": { + } + }, "auditlog.ConversationResult": { "type": "object", "properties": { @@ -4013,6 +4711,9 @@ "api_key_hash": { "type": "string" }, + "error_code": { + "type": "string" + }, "error_message": { "description": "Error details (message can be long, so kept in JSON)", "type": "string" @@ -4149,32 +4850,15 @@ } } }, - "auditlog.LogListResult": { - "type": "object", - "properties": { - "entries": { - "type": "array", - "items": { - "$ref": "#/components/schemas/auditlog.LogEntry" - } - }, - "limit": { - "type": "integer" - }, - "offset": { - "type": "integer" - }, - "total": { - "type": "integer" - } - } - }, "auditlog.WorkflowFeaturesSnapshot": { "type": "object", "properties": { "audit": { "type": "boolean" }, + "budget": { + "type": "boolean" + }, "cache": { "type": "boolean" }, @@ -4189,6 +4873,38 @@ } } }, + "budget.Settings": { + "type": "object", + "properties": { + "daily_reset_hour": { + "type": "integer" + }, + "daily_reset_minute": { + "type": "integer" + }, + "monthly_reset_day": { + "type": "integer" + }, + "monthly_reset_hour": { + "type": "integer" + }, + "monthly_reset_minute": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "weekly_reset_hour": { + "type": "integer" + }, + "weekly_reset_minute": { + "type": "integer" + }, + "weekly_reset_weekday": { + "type": "integer" + } + } + }, "core.BatchError": { "type": "object", "properties": { @@ -5606,6 +6322,38 @@ } } }, + "usage.RequestUsageSummary": { + "type": "object", + "properties": { + "cache_write_input_tokens": { + "type": "integer" + }, + "cached_input_ratio": { + "type": "number" + }, + "cached_input_tokens": { + "type": "integer" + }, + "entries": { + "type": "integer" + }, + "estimated_cached_characters": { + "type": "integer" + }, + "input_tokens": { + "type": "integer" + }, + "output_tokens": { + "type": "integer" + }, + "total_tokens": { + "type": "integer" + }, + "uncached_input_tokens": { + "type": "integer" + } + } + }, "usage.UsageLogEntry": { "type": "object", "properties": { diff --git a/internal/admin/dashboard/static/css/dashboard.css b/internal/admin/dashboard/static/css/dashboard.css index ea27766f..3c4a4570 100644 --- a/internal/admin/dashboard/static/css/dashboard.css +++ b/internal/admin/dashboard/static/css/dashboard.css @@ -183,6 +183,7 @@ body.dashboard-modal-open { width: 28px; height: 28px; flex-shrink: 0; + color: var(--accent); } .sidebar-logo svg { @@ -1875,6 +1876,23 @@ textarea:focus { border-color: var(--accent); } +.form-input { + width: 100%; + min-height: 38px; + padding: 8px 10px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 13px; + font-family: inherit; + outline: none; +} + +.form-input:focus { + border-color: var(--accent); +} + .alias-checkbox { display: inline-flex; align-items: center; @@ -3072,6 +3090,19 @@ body.conversation-drawer-open { border-color: color-mix(in srgb, var(--danger) 75%, var(--border)); } +.pagination-btn-danger { + color: #fff; + border-color: color-mix(in srgb, var(--danger) 76%, #000 12%); + background: var(--danger); + font-weight: 600; + box-shadow: 0 10px 22px color-mix(in srgb, var(--danger) 20%, transparent); +} + +.pagination-btn-danger:hover:not(:disabled) { + background: color-mix(in srgb, var(--danger) 90%, #fff 10%); + border-color: color-mix(in srgb, var(--danger) 82%, #000 14%); +} + .pagination-btn-with-icon { display: inline-flex; align-items: center; @@ -3384,6 +3415,449 @@ body.conversation-drawer-open { line-height: 1.55; } +.budget-settings-section { + width: 100%; +} + +.budget-help-notice { + margin-bottom: 16px; +} + +.budget-list { + display: grid; + gap: 10px; +} + +.budget-row { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-items: center; + gap: 16px; + padding: 12px 14px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.budget-row-main { + min-width: 0; +} + +.budget-row-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + gap: 10px; + min-width: 0; +} + +.budget-user-path { + display: inline-flex; + align-items: center; + justify-self: start; + width: fit-content; + max-width: 100%; + min-height: 24px; + padding: 2px 8px; + border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--border)); + border-radius: 6px; + background: color-mix(in srgb, var(--accent) 9%, var(--bg)); + color: var(--text); + font-family: "SF Mono", Menlo, Consolas, monospace; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.budget-row-meta { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + color: var(--text-muted); + font-size: 12px; +} + +.budget-source { + padding: 2px 7px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg); + color: var(--text-muted); + font-size: 11px; +} + +.budget-row-period { + display: flex; + justify-content: center; + min-width: 0; +} + +.budget-row-controls { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-width: 0; +} + +.budget-sort-control { + align-items: center; + gap: 8px; +} + +.budget-sort-control label { + color: var(--text-muted); + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.budget-sort-select { + background-color: var(--bg-surface); + min-width: 132px; +} + +.budget-sort-select:hover { + background-color: var(--bg-surface-hover); +} + +.budget-bars { + display: grid; + gap: 6px; + margin-top: 10px; +} + +.budget-bar-line { + display: grid; + grid-template-columns: 118px minmax(0, 1fr); + align-items: center; + gap: 10px; +} + +.budget-bar-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + color: var(--text-muted); + font-size: 11px; + line-height: 1; +} + +.budget-bar-percent { + font-weight: 700; +} + +.budget-bar-track { + position: relative; + height: 16px; + overflow: hidden; + border-radius: 999px; + background: color-mix(in srgb, var(--border) 75%, var(--bg)); +} + +.budget-bar-fill { + height: 100%; + min-width: 0; + border-radius: inherit; + transition: width 0.2s ease; +} + +.budget-bar-fill-usage { + background: color-mix(in srgb, var(--success) 82%, var(--accent)); +} + +.budget-bar-fill-danger { + background: var(--danger); +} + +.budget-bar-fill-period-monthly { + background: #30302c; +} + +.budget-bar-fill-period-weekly { + background: #68765c; +} + +.budget-bar-fill-period-daily { + background: #b5652d; +} + +.budget-bar-fill-period-hourly { + background: #783f22; +} + +.budget-bar-fill-period-custom { + background: #bfa584; +} + +.budget-bar-text-row { + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + color: var(--text); +} + +.budget-bar-text-row-on-fill { + color: #fff; + clip-path: inset(0 calc(100% - var(--budget-progress, 0%)) 0 0); +} + +.budget-bar-track-period-custom .budget-bar-text-row-on-fill { + color: #3f332a; +} + +.budget-bar-text { + position: absolute; + top: 50%; + max-width: min(44%, 190px); + overflow: hidden; + font-size: 11px; + font-weight: 700; + line-height: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.budget-bar-text-start { + left: 8px; + transform: translateY(-50%); +} + +.budget-bar-text-center { + left: 50%; + max-width: min(46%, 240px); + transform: translate(-50%, -50%); +} + +.budget-bar-text-end { + right: 8px; + max-width: min(34%, 180px); + text-align: right; + transform: translateY(-50%); +} + +.budget-period-label { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 2px 7px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text); + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} + +.budget-period-icon { + width: 12px; + height: 12px; + flex: 0 0 12px; + stroke-width: 2.2; +} + +.budget-period-label-monthly { + border-color: color-mix(in srgb, #30302c 62%, var(--border)); + background: color-mix(in srgb, #30302c 12%, var(--bg)); + color: color-mix(in srgb, #30302c 34%, var(--text) 66%); +} + +.budget-period-label-weekly { + border-color: color-mix(in srgb, #68765c 62%, var(--border)); + background: color-mix(in srgb, #68765c 12%, var(--bg)); + color: color-mix(in srgb, #68765c 34%, var(--text) 66%); +} + +.budget-period-label-daily { + border-color: color-mix(in srgb, #b5652d 62%, var(--border)); + background: color-mix(in srgb, #b5652d 12%, var(--bg)); + color: color-mix(in srgb, #b5652d 34%, var(--text) 66%); +} + +.budget-period-label-hourly { + border-color: color-mix(in srgb, #783f22 62%, var(--border)); + background: color-mix(in srgb, #783f22 12%, var(--bg)); + color: color-mix(in srgb, #783f22 34%, var(--text) 66%); +} + +.budget-period-label-custom { + border-style: dashed; + border-color: color-mix(in srgb, #bfa584 68%, var(--border)); + background: color-mix(in srgb, #bfa584 16%, var(--bg)); + color: color-mix(in srgb, #8b6f4f 34%, var(--text) 66%); +} + +[data-theme="light"] .budget-period-label-monthly { + color: #30302c; +} + +[data-theme="light"] .budget-period-label-weekly { + color: #68765c; +} + +[data-theme="light"] .budget-period-label-daily { + color: #b5652d; +} + +[data-theme="light"] .budget-period-label-hourly { + color: #783f22; +} + +[data-theme="light"] .budget-period-label-custom { + color: #8b6f4f; +} + +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) .budget-period-label-monthly { + color: #30302c; + } + + :root:not([data-theme="dark"]) .budget-period-label-weekly { + color: #68765c; + } + + :root:not([data-theme="dark"]) .budget-period-label-daily { + color: #b5652d; + } + + :root:not([data-theme="dark"]) .budget-period-label-hourly { + color: #783f22; + } + + :root:not([data-theme="dark"]) .budget-period-label-custom { + color: #8b6f4f; + } +} + +.budget-row-actions { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 6px; +} + +.budget-action-btn { + width: 28px; + height: 28px; + min-width: 0; + gap: 0; + padding: 0; + justify-content: center; + overflow: hidden; + white-space: nowrap; + transition: + width 0.18s ease, + border-color 0.15s, + background 0.15s, + color 0.15s; +} + +.budget-action-btn:hover, +.budget-action-btn:focus-visible { + width: 82px; + gap: 6px; + padding: 0 9px; +} + +.budget-action-label { + max-width: 0; + overflow: hidden; + opacity: 0; + transition: + max-width 0.18s ease, + opacity 0.12s ease; +} + +.budget-action-btn:hover .budget-action-label, +.budget-action-btn:focus-visible .budget-action-label { + max-width: 58px; + opacity: 1; +} + +.budget-action-btn-warning { + color: var(--warning); + border-color: color-mix(in srgb, var(--warning) 50%, var(--border)); +} + +.budget-action-icon { + width: 14px; + height: 14px; + flex: 0 0 14px; +} + +.budget-editor { + background: color-mix(in srgb, var(--bg-surface) 86%, var(--bg) 14%); +} + +.budget-settings-grid { + display: grid; + gap: 12px; +} + +.budget-settings-row { + display: grid; + grid-template-columns: 96px minmax(170px, 1fr) minmax(110px, 140px) minmax(110px, 140px) minmax(220px, 280px); + align-items: start; + gap: 12px; +} + +.budget-settings-period { + align-self: end; + color: var(--text); + font-size: 12px; + font-weight: 700; + letter-spacing: 0; + min-height: 35px; + padding-bottom: 9px; + text-transform: uppercase; +} + +.budget-settings-spacer { + min-height: 1px; +} + +.budget-settings-help-cell { + align-self: start; + min-width: 0; + min-height: 35px; +} + +.budget-settings-help-cell .inline-help-copy { + margin: 22px 0 0; + max-width: 280px; + font-size: 12px; + line-height: 1.35; +} + +.budget-settings-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.budget-reset-dialog { + max-width: 460px; +} + +.budget-override-dialog-backdrop { + z-index: 100; +} + +.budget-override-dialog-shell { + z-index: 110; +} + +.budget-override-dialog { + max-width: 460px; +} + .settings-refresh-icon, .alias-create-icon, .form-action-icon { @@ -3708,6 +4182,16 @@ body.conversation-drawer-open { font-size: 11px; } + .budget-row { + grid-template-columns: 1fr; + } + .budget-row-controls { + flex-wrap: wrap; + } + .budget-bar-line { + grid-template-columns: 1fr; + gap: 5px; + } .form-grid { grid-template-columns: 1fr; } @@ -3831,6 +4315,21 @@ body.conversation-drawer-open { .settings-form-grid { grid-template-columns: 1fr; } + .budget-settings-grid { + grid-template-columns: 1fr; + } + .budget-settings-row { + grid-template-columns: 1fr; + } + .budget-settings-spacer { + display: none; + } + .budget-settings-actions { + width: 100%; + } + .budget-settings-actions .pagination-btn { + width: 100%; + } /* Date picker mobile */ .date-picker-dropdown { diff --git a/internal/admin/dashboard/static/js/dashboard.js b/internal/admin/dashboard/static/js/dashboard.js index 610667bf..16533219 100644 --- a/internal/admin/dashboard/static/js/dashboard.js +++ b/internal/admin/dashboard/static/js/dashboard.js @@ -187,6 +187,7 @@ function dashboard() { page = [ "overview", "usage", + "budgets", "models", "workflows", "audit-logs", @@ -219,10 +220,16 @@ function dashboard() { ) { this.fetchGuardrailsPage(); } + if (page === "budgets" && typeof this.fetchBudgetsPage === "function") { + this.fetchBudgetsPage(); + } if (page === "settings") { if (typeof this.ensureTimezoneOptions === "function") { this.ensureTimezoneOptions(); } + if (typeof this.fetchBudgetSettings === "function") { + this.fetchBudgetSettings(); + } } if (page === "overview") this.renderChart(); if (page === "usage") this.fetchUsagePage(); @@ -382,7 +389,9 @@ function dashboard() { (this.aliasFormOpen || this.modelOverrideFormOpen)) || (this.page === "workflows" && this.workflowFormOpen) || (this.page === "guardrails" && this.guardrailFormOpen) || - (this.page === "auth-keys" && this.authKeyFormOpen) + (this.page === "auth-keys" && this.authKeyFormOpen) || + (this.page === "budgets" && this.budgetFormOpen) || + this.budgetResetDialogOpen ); }, @@ -477,6 +486,12 @@ function dashboard() { if (typeof this.fetchWorkflowsPage === "function") { requests.push(this.fetchWorkflowsPage()); } + if ( + this.page === "budgets" && + typeof this.fetchBudgetsPage === "function" + ) { + requests.push(this.fetchBudgetsPage()); + } if ( this.hasCalendarModule && typeof this.fetchCalendarData === "function" @@ -949,6 +964,12 @@ function dashboard() { : null, "dashboardGuardrailsModule", ), + resolveModuleFactory( + typeof dashboardBudgetsModule === "function" + ? dashboardBudgetsModule + : null, + "dashboardBudgetsModule", + ), resolveModuleFactory( typeof dashboardWorkflowsModule === "function" ? dashboardWorkflowsModule diff --git a/internal/admin/dashboard/static/js/modules/budgets.js b/internal/admin/dashboard/static/js/modules/budgets.js new file mode 100644 index 00000000..0415ed8b --- /dev/null +++ b/internal/admin/dashboard/static/js/modules/budgets.js @@ -0,0 +1,883 @@ +(function(global) { + function dashboardBudgetsModule() { + return { + budgetSettings: { + daily_reset_hour: 0, + daily_reset_minute: 0, + weekly_reset_weekday: 1, + weekly_reset_hour: 0, + weekly_reset_minute: 0, + monthly_reset_day: 1, + monthly_reset_hour: 0, + monthly_reset_minute: 0 + }, + budgetSettingsLoading: false, + budgetSettingsSaving: false, + budgetSettingsNotice: '', + budgetSettingsError: '', + budgetResetDialogOpen: false, + budgetResetConfirmation: '', + budgetResetLoading: false, + budgets: [], + budgetsAvailable: true, + budgetsLoading: false, + budgetFetchPromise: null, + budgetFilter: '', + budgetSortBy: 'user_path', + budgetError: '', + budgetNotice: '', + budgetFormOpen: false, + budgetFormSubmitting: false, + budgetFormError: '', + budgetEditing: false, + budgetOverrideDialogOpen: false, + budgetOverridePendingPayload: null, + budgetOverrideExistingBudget: null, + budgetResettingKey: '', + budgetDeletingKey: '', + budgetForm: { + user_path: '/', + period: 'daily', + period_seconds: 86400, + amount: '', + source: 'manual' + }, + + budgetManagementEnabled() { + return typeof this.workflowRuntimeBooleanFlag === 'function' + ? this.workflowRuntimeBooleanFlag('BUDGETS_ENABLED', true) + : true; + }, + + defaultBudgetForm() { + return { + user_path: '/', + period: 'daily', + period_seconds: 86400, + amount: '', + source: 'manual' + }; + }, + + budgetPeriodOptions() { + return [ + { value: 'hourly', label: 'Hourly' }, + { value: 'daily', label: 'Daily' }, + { value: 'weekly', label: 'Weekly' }, + { value: 'monthly', label: 'Monthly' }, + { value: 'custom', label: 'Custom seconds' } + ]; + }, + + budgetPeriodSeconds(period) { + switch (String(period || '').trim().toLowerCase()) { + case 'hourly': + return 3600; + case 'daily': + return 86400; + case 'weekly': + return 604800; + case 'monthly': + return 2592000; + default: + return 0; + } + }, + + budgetPeriodFromSeconds(seconds) { + switch (Number(seconds || 0)) { + case 3600: + return 'hourly'; + case 86400: + return 'daily'; + case 604800: + return 'weekly'; + case 2592000: + return 'monthly'; + default: + return 'custom'; + } + }, + + syncBudgetPeriodSeconds() { + const period = String(this.budgetForm && this.budgetForm.period || '').trim(); + const seconds = this.budgetPeriodSeconds(period); + if (seconds > 0) { + this.budgetForm.period_seconds = seconds; + } + }, + + budgetKey(item) { + return String(item && item.user_path || '') + ':' + String(item && item.period_seconds || ''); + }, + + existingBudgetForPayload(payload) { + if (!payload || !Array.isArray(this.budgets)) { + return null; + } + const key = this.budgetKey(payload); + return this.budgets.find((item) => this.budgetKey(item) === key) || null; + }, + + budgetUserPathValidationError(value) { + const trimmed = String(value || '').trim(); + if (!trimmed) { + return 'User path is required.'; + } + const raw = trimmed.startsWith('/') ? trimmed : '/' + trimmed; + const segments = raw.split('/'); + for (const part of segments) { + const segment = String(part || '').trim(); + if (!segment) { + continue; + } + if (segment === '.' || segment === '..') { + return 'User path cannot contain "." or ".." segments.'; + } + if (segment.includes(':')) { + return 'User path cannot contain ":" segments.'; + } + } + return ''; + }, + + normalizeBudgetUserPath(value) { + if (this.budgetUserPathValidationError(value)) { + return ''; + } + const trimmed = String(value || '').trim(); + const raw = trimmed.startsWith('/') ? trimmed : '/' + trimmed; + const segments = raw.split('/'); + const canonical = []; + for (const part of segments) { + const segment = String(part || '').trim(); + if (segment) { + canonical.push(segment); + } + } + return canonical.length ? '/' + canonical.join('/') : '/'; + }, + + budgetInputUserPath(value) { + const body = String(value || '').trimStart().replace(/^\/+/, ''); + return '/' + body; + }, + + setBudgetFormUserPath(value) { + if (!this.budgetForm) { + return; + } + this.budgetForm.user_path = this.budgetInputUserPath(value); + }, + + normalizeBudgetListPayload(payload) { + if (Array.isArray(payload)) { + return payload; + } + if (payload && Array.isArray(payload.budgets)) { + return payload.budgets; + } + return []; + }, + + filteredBudgets() { + const filter = String(this.budgetFilter || '').trim().toLowerCase(); + let items; + if (!filter) { + items = this.budgets.slice(); + } else { + items = this.budgets.filter((item) => { + return this.budgetFilterText(item).includes(filter); + }); + } + return this.sortBudgets(items); + }, + + budgetFilterText(item) { + const seconds = Number(item && item.period_seconds || 0); + return [ + item && item.user_path, + this.budgetPeriodLabel(item), + this.budgetPeriodFromSeconds(seconds), + seconds ? String(seconds) + 's' : '', + seconds ? String(seconds) + ' seconds' : '' + ].join(' ').toLowerCase(); + }, + + sortBudgets(items) { + const sorted = Array.isArray(items) ? items.slice() : []; + const sortBy = String(this.budgetSortBy || 'user_path'); + sorted.sort((a, b) => { + const pathCompare = String(a && a.user_path || '').localeCompare(String(b && b.user_path || '')); + const periodCompare = Number(b && b.period_seconds || 0) - Number(a && a.period_seconds || 0); + if (sortBy === 'period') { + return periodCompare || pathCompare; + } + return pathCompare || periodCompare; + }); + return sorted; + }, + + async budgetResponseMessage(res, fallback) { + try { + const payload = await res.json(); + if (payload && payload.error && payload.error.message) { + return payload.error.message; + } + } catch (_) { + // Ignore invalid or empty responses and return the fallback message. + } + return fallback; + }, + + async fetchBudgetsPage() { + if (!this.budgetManagementEnabled()) { + this.budgets = []; + this.budgetsAvailable = false; + this.budgetError = ''; + return; + } + if (this.budgetFetchPromise) { + return this.budgetFetchPromise; + } + this.budgetFetchPromise = this.fetchBudgets().finally(() => { + this.budgetFetchPromise = null; + }); + return this.budgetFetchPromise; + }, + + async fetchBudgets() { + this.budgetsLoading = true; + this.budgetError = ''; + try { + const request = typeof this.requestOptions === 'function' ? this.requestOptions() : { headers: this.headers() }; + const res = await fetch('/admin/api/v1/budgets', request); + if (res.status === 503) { + this.budgetsAvailable = false; + this.budgets = []; + return; + } + const handled = this.handleFetchResponse(res, 'budgets', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } + this.budgetsAvailable = true; + if (!handled) { + this.budgetError = 'Unable to load budgets.'; + return; + } + this.budgets = this.normalizeBudgetListPayload(await res.json()); + if (typeof this.renderIconsAfterUpdate === 'function') { + this.renderIconsAfterUpdate(); + } + } catch (e) { + console.error('Failed to fetch budgets:', e); + this.budgets = []; + this.budgetError = 'Unable to load budgets.'; + } finally { + this.budgetsLoading = false; + } + }, + + openBudgetForm(item) { + this.budgetEditing = !!item; + this.budgetFormError = ''; + this.budgetError = ''; + this.budgetNotice = ''; + if (item) { + const periodSeconds = Number(item.period_seconds || 0); + this.budgetForm = { + user_path: String(item.user_path || ''), + period: this.budgetPeriodFromSeconds(periodSeconds), + period_seconds: periodSeconds, + amount: String(item.amount || ''), + source: String(item.source || 'manual') + }; + } else { + this.budgetForm = this.defaultBudgetForm(); + } + this.budgetFormOpen = true; + if (typeof this.renderIconsAfterUpdate === 'function') { + this.renderIconsAfterUpdate(); + } + if (typeof this.$nextTick === 'function') { + this.$nextTick(() => { + const refs = this.$refs || {}; + const input = this.budgetEditing ? refs.budgetAmountInput : refs.budgetUserPathInput; + if (input && typeof input.focus === 'function') { + input.focus({ preventScroll: true }); + } + }); + } + }, + + closeBudgetForm() { + this.closeBudgetOverrideDialog(); + this.budgetFormOpen = false; + this.budgetFormSubmitting = false; + this.budgetFormError = ''; + this.budgetEditing = false; + this.budgetForm = this.defaultBudgetForm(); + }, + + budgetFormPayload() { + const userPathError = this.budgetUserPathValidationError(this.budgetForm.user_path); + if (userPathError) { + this.budgetFormError = userPathError; + return null; + } + const amount = Number(this.budgetForm.amount); + if (!Number.isFinite(amount) || amount <= 0) { + this.budgetFormError = 'Amount must be greater than 0.'; + return null; + } + const period = String(this.budgetForm.period || '').trim(); + let periodSeconds = this.budgetPeriodSeconds(period); + if (period === 'custom') { + periodSeconds = Number(this.budgetForm.period_seconds); + } + if (!Number.isFinite(periodSeconds) || periodSeconds <= 0) { + this.budgetFormError = 'Period seconds must be greater than 0.'; + return null; + } + return { + user_path: this.normalizeBudgetUserPath(this.budgetForm.user_path), + period_seconds: Math.trunc(periodSeconds), + amount, + source: String(this.budgetForm.source || 'manual').trim() || 'manual' + }; + }, + + budgetAPIPath(userPath, periodSeconds) { + const encodedPath = encodeURIComponent(this.normalizeBudgetUserPath(userPath)); + const encodedPeriod = encodeURIComponent(String(Math.trunc(Number(periodSeconds || 0)))); + return '/admin/api/v1/budgets/' + encodedPath + '/' + encodedPeriod; + }, + + openBudgetOverrideDialog(existing, payload) { + this.budgetOverrideExistingBudget = existing || null; + this.budgetOverridePendingPayload = payload || null; + this.budgetOverrideDialogOpen = true; + if (typeof this.renderIconsAfterUpdate === 'function') { + this.renderIconsAfterUpdate(); + } + if (typeof this.$nextTick === 'function') { + this.$nextTick(() => { + const refs = this.$refs || {}; + const button = refs.budgetOverrideCancelButton; + if (button && typeof button.focus === 'function') { + button.focus({ preventScroll: true }); + } + }); + } + }, + + closeBudgetOverrideDialog() { + this.budgetOverrideDialogOpen = false; + this.budgetOverridePendingPayload = null; + this.budgetOverrideExistingBudget = null; + }, + + budgetAmountLabel(value) { + const amount = Number(value); + if (!Number.isFinite(amount)) { + return 'N/A'; + } + if (typeof this.formatCost === 'function') { + return this.formatCost(amount); + } + return '$' + amount.toFixed(4); + }, + + budgetOverrideDialogMessage() { + const payload = this.budgetOverridePendingPayload || {}; + const existing = this.budgetOverrideExistingBudget || {}; + const label = String(payload.user_path || existing.user_path || '') + ' ' + this.budgetPeriodLabel({ + period_seconds: payload.period_seconds || existing.period_seconds, + period_label: existing.period_label + }); + return 'A budget for "' + label + '" already exists. Saving will override the current ' + + this.budgetAmountLabel(existing.amount) + ' limit with ' + + this.budgetAmountLabel(payload.amount) + '.'; + }, + + async confirmBudgetOverride() { + if (!this.budgetOverridePendingPayload) { + this.closeBudgetOverrideDialog(); + return; + } + const payload = this.budgetOverridePendingPayload; + this.closeBudgetOverrideDialog(); + await this.saveBudgetPayload(payload); + }, + + async submitBudgetForm() { + if (this.budgetFormSubmitting) { + return; + } + const payload = this.budgetFormPayload(); + if (!payload) { + return; + } + if (!this.budgetEditing) { + const existing = this.existingBudgetForPayload(payload); + if (existing) { + this.openBudgetOverrideDialog(existing, payload); + return; + } + } + await this.saveBudgetPayload(payload); + }, + + async saveBudgetPayload(payload) { + if (this.budgetFormSubmitting || !payload) { + return; + } + this.budgetFormSubmitting = true; + this.budgetFormError = ''; + this.budgetError = ''; + this.budgetNotice = ''; + try { + const request = typeof this.requestOptions === 'function' + ? this.requestOptions({ + method: 'PUT', + body: JSON.stringify({ amount: payload.amount }) + }) + : { + method: 'PUT', + headers: this.headers(), + body: JSON.stringify({ amount: payload.amount }) + }; + const res = await fetch(this.budgetAPIPath(payload.user_path, payload.period_seconds), request); + if (res.status === 503) { + this.budgetsAvailable = false; + this.budgetFormError = 'Budget management is unavailable.'; + return; + } + const handled = this.handleFetchResponse(res, 'budget', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { + this.budgetFormError = await this.budgetResponseMessage(res, 'Unable to save budget.'); + return; + } + this.closeBudgetForm(); + await this.fetchBudgets(); + this.budgetNotice = 'Budget saved.'; + } catch (e) { + console.error('Failed to save budget:', e); + this.budgetFormError = 'Unable to save budget.'; + } finally { + this.budgetFormSubmitting = false; + } + }, + + async resetBudget(item) { + if (!item) { + return; + } + const key = this.budgetKey(item); + if (this.budgetResettingKey === key) { + return; + } + const label = String(item.user_path || '') + ' ' + this.budgetPeriodLabel(item); + if (global.confirm && !global.confirm('Reset budget "' + label + '"?')) { + return; + } + this.budgetResettingKey = key; + this.budgetError = ''; + this.budgetNotice = ''; + try { + const request = typeof this.requestOptions === 'function' + ? this.requestOptions({ + method: 'POST', + body: JSON.stringify({ + user_path: item.user_path, + period_seconds: item.period_seconds + }) + }) + : { + method: 'POST', + headers: this.headers(), + body: JSON.stringify({ + user_path: item.user_path, + period_seconds: item.period_seconds + }) + }; + const res = await fetch('/admin/api/v1/budgets/reset-one', request); + if (res.status === 503) { + this.budgetsAvailable = false; + this.budgetError = 'Budget management is unavailable.'; + return; + } + const handled = this.handleFetchResponse(res, 'budget reset', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { + this.budgetError = await this.budgetResponseMessage(res, 'Unable to reset budget.'); + return; + } + await this.fetchBudgets(); + this.budgetNotice = 'Budget reset.'; + } catch (e) { + console.error('Failed to reset budget:', e); + this.budgetError = 'Unable to reset budget.'; + } finally { + this.budgetResettingKey = ''; + } + }, + + async deleteBudget(item) { + if (!item) { + return; + } + const key = this.budgetKey(item); + if (this.budgetDeletingKey === key) { + return; + } + const label = String(item.user_path || '') + ' ' + this.budgetPeriodLabel(item); + if (global.confirm && !global.confirm('Delete budget "' + label + '"? This cannot be undone.')) { + return; + } + this.budgetDeletingKey = key; + this.budgetError = ''; + this.budgetNotice = ''; + try { + const request = typeof this.requestOptions === 'function' + ? this.requestOptions({ + method: 'DELETE' + }) + : { + method: 'DELETE', + headers: this.headers() + }; + const res = await fetch(this.budgetAPIPath(item.user_path, item.period_seconds), request); + if (res.status === 503) { + this.budgetsAvailable = false; + this.budgetError = 'Budget management is unavailable.'; + return; + } + const handled = this.handleFetchResponse(res, 'budget delete', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { + this.budgetError = await this.budgetResponseMessage(res, 'Unable to delete budget.'); + return; + } + this.budgets = this.normalizeBudgetListPayload(await res.json()); + if (typeof this.renderIconsAfterUpdate === 'function') { + this.renderIconsAfterUpdate(); + } + this.budgetNotice = 'Budget deleted.'; + } catch (e) { + console.error('Failed to delete budget:', e); + this.budgetError = 'Unable to delete budget.'; + } finally { + this.budgetDeletingKey = ''; + } + }, + + budgetRatio(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + return 0; + } + return parsed; + }, + + budgetPercentFromRatio(value, clamp) { + const ratio = this.budgetRatio(value); + const bounded = clamp ? Math.min(ratio, 1) : ratio; + return Math.round(bounded * 1000) / 10; + }, + + budgetUsageRatio(item) { + return this.budgetRatio(item && item.usage_ratio); + }, + + budgetUsagePercent(item) { + return this.budgetPercentFromRatio(this.budgetUsageRatio(item), true); + }, + + budgetPeriodPercent(item) { + return this.budgetPercentFromRatio(item && item.period_ratio, true); + }, + + budgetUsagePercentLabel(item) { + return this.budgetPercentFromRatio(this.budgetUsageRatio(item), false).toFixed(1).replace(/\.0$/, '') + '%'; + }, + + budgetPeriodPercentLabel(item) { + return this.budgetPeriodPercent(item).toFixed(1).replace(/\.0$/, '') + '%'; + }, + + budgetPeriodLabel(item) { + const seconds = Number(item && item.period_seconds || 0); + switch (seconds) { + case 3600: + return 'Hourly'; + case 86400: + return 'Daily'; + case 604800: + return 'Weekly'; + case 2592000: + return 'Monthly'; + default: { + const label = String(item && item.period_label || '').trim(); + return label ? 'Custom ' + label : 'Custom ' + String(seconds || '') + 's'; + } + } + }, + + budgetPeriodClass(item) { + const seconds = Number(item && item.period_seconds || 0); + switch (seconds) { + case 3600: + return 'budget-period-label-hourly'; + case 86400: + return 'budget-period-label-daily'; + case 604800: + return 'budget-period-label-weekly'; + case 2592000: + return 'budget-period-label-monthly'; + default: + return 'budget-period-label-custom'; + } + }, + + budgetPeriodBarClass(item) { + return this.budgetPeriodClass(item).replace('budget-period-label-', 'budget-bar-fill-period-'); + }, + + budgetPeriodTrackClass(item) { + return this.budgetPeriodClass(item).replace('budget-period-label-', 'budget-bar-track-period-'); + }, + + budgetPeriodIcon(item) { + const seconds = Number(item && item.period_seconds || 0); + switch (seconds) { + case 3600: + return 'clock'; + case 86400: + return 'sun'; + case 604800: + return 'calendar-days'; + case 2592000: + return 'calendar'; + default: + return 'settings-2'; + } + }, + + budgetPeriodDurationLabel(item) { + const seconds = Number(item && item.period_seconds || 0); + switch (seconds) { + case 3600: + return '1 hour'; + case 86400: + return '1 day'; + case 604800: + return '1 week'; + case 2592000: + return '1 month'; + default: + return this.formatBudgetPeriodSeconds(seconds); + } + }, + + formatBudgetPeriodSeconds(seconds) { + const normalized = Math.max(0, Math.trunc(Number(seconds || 0))); + return normalized + ' ' + (normalized === 1 ? 'second' : 'seconds'); + }, + + budgetSourceLabel(item) { + const source = String(item && item.source || '').trim(); + return source || 'manual'; + }, + + budgetSourceTitle(item) { + const source = this.budgetSourceLabel(item).toLowerCase(); + if (source === 'manual') { + return 'Created from the dashboard.'; + } + if (source === 'config') { + return 'Loaded from configuration.'; + } + return 'Budget source: ' + source; + }, + + budgetRemainingLabel(item) { + const remaining = Number(item && item.remaining); + if (!Number.isFinite(remaining)) { + return ''; + } + if (remaining < 0) { + return this.formatCost(Math.abs(remaining)) + ' over'; + } + return this.formatCost(remaining) + ' remaining'; + }, + + budgetWeekdays() { + return [ + { value: 0, label: 'Sunday' }, + { value: 1, label: 'Monday' }, + { value: 2, label: 'Tuesday' }, + { value: 3, label: 'Wednesday' }, + { value: 4, label: 'Thursday' }, + { value: 5, label: 'Friday' }, + { value: 6, label: 'Saturday' } + ]; + }, + + normalizeBudgetSettings(payload) { + const current = this.budgetSettings || {}; + const integerValue = (value, fallback) => { + if (value === '') { + return fallback; + } + const parsed = Number(value); + return Number.isFinite(parsed) && Number.isInteger(parsed) ? Math.trunc(parsed) : fallback; + }; + const currentValue = (key, fallback) => integerValue(current[key], fallback); + const numberValue = (key, fallback) => { + if (!payload) { + return fallback; + } + return integerValue(payload[key], fallback); + }; + return { + daily_reset_hour: numberValue('daily_reset_hour', currentValue('daily_reset_hour', 0)), + daily_reset_minute: numberValue('daily_reset_minute', currentValue('daily_reset_minute', 0)), + weekly_reset_weekday: numberValue('weekly_reset_weekday', currentValue('weekly_reset_weekday', 1)), + weekly_reset_hour: numberValue('weekly_reset_hour', currentValue('weekly_reset_hour', 0)), + weekly_reset_minute: numberValue('weekly_reset_minute', currentValue('weekly_reset_minute', 0)), + monthly_reset_day: numberValue('monthly_reset_day', currentValue('monthly_reset_day', 1)), + monthly_reset_hour: numberValue('monthly_reset_hour', currentValue('monthly_reset_hour', 0)), + monthly_reset_minute: numberValue('monthly_reset_minute', currentValue('monthly_reset_minute', 0)) + }; + }, + + budgetSettingsPayload() { + return this.normalizeBudgetSettings(this.budgetSettings); + }, + + async fetchBudgetSettings() { + if (!this.budgetManagementEnabled()) { + this.budgetSettingsError = ''; + return; + } + this.budgetSettingsLoading = true; + this.budgetSettingsError = ''; + try { + const request = this.requestOptions(); + const res = await fetch('/admin/api/v1/budgets/settings', request); + const handled = this.handleFetchResponse(res, 'budget settings', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { + this.budgetSettingsError = 'Unable to load budget settings.'; + return; + } + this.budgetSettings = this.normalizeBudgetSettings(await res.json()); + } catch (e) { + console.error('Failed to fetch budget settings:', e); + this.budgetSettingsError = 'Unable to load budget settings.'; + } finally { + this.budgetSettingsLoading = false; + } + }, + + async saveBudgetSettings() { + if (this.budgetSettingsSaving) { + return; + } + this.budgetSettingsSaving = true; + this.budgetSettingsNotice = ''; + this.budgetSettingsError = ''; + try { + const request = this.requestOptions({ + method: 'PUT', + body: JSON.stringify(this.budgetSettingsPayload()) + }); + const res = await fetch('/admin/api/v1/budgets/settings', request); + const handled = this.handleFetchResponse(res, 'budget settings', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { + this.budgetSettingsError = 'Unable to save budget settings.'; + return; + } + this.budgetSettings = this.normalizeBudgetSettings(await res.json()); + this.budgetSettingsNotice = 'Budget settings saved.'; + } catch (e) { + console.error('Failed to save budget settings:', e); + this.budgetSettingsError = 'Unable to save budget settings.'; + } finally { + this.budgetSettingsSaving = false; + } + }, + + openBudgetResetDialog() { + this.budgetResetConfirmation = ''; + this.budgetSettingsError = ''; + this.budgetResetDialogOpen = true; + setTimeout(() => { + const input = document.getElementById('budget-reset-confirmation'); + if (input && typeof input.focus === 'function') { + input.focus(); + } + }, 0); + }, + + closeBudgetResetDialog() { + this.budgetResetDialogOpen = false; + this.budgetResetConfirmation = ''; + }, + + async resetBudgets() { + if (this.budgetResetLoading) { + return; + } + if (String(this.budgetResetConfirmation || '').trim().toLowerCase() !== 'reset') { + this.budgetSettingsError = 'Type reset to confirm.'; + return; + } + this.budgetResetLoading = true; + this.budgetSettingsNotice = ''; + this.budgetSettingsError = ''; + try { + const request = this.requestOptions({ + method: 'POST', + body: JSON.stringify({ confirmation: 'reset' }) + }); + const res = await fetch('/admin/api/v1/budgets/reset', request); + const handled = this.handleFetchResponse(res, 'budget reset', request); + if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { + return; + } + if (!handled) { + this.budgetSettingsError = 'Unable to reset budgets.'; + return; + } + this.closeBudgetResetDialog(); + if (this.page === 'budgets' && typeof this.fetchBudgets === 'function') { + await this.fetchBudgets(); + } + this.budgetSettingsNotice = 'Budgets reset.'; + } catch (e) { + console.error('Failed to reset budgets:', e); + this.budgetSettingsError = 'Unable to reset budgets.'; + } finally { + this.budgetResetLoading = false; + } + } + }; + } + + global.dashboardBudgetsModule = dashboardBudgetsModule; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/internal/admin/dashboard/static/js/modules/budgets.test.cjs b/internal/admin/dashboard/static/js/modules/budgets.test.cjs new file mode 100644 index 00000000..6f9d5408 --- /dev/null +++ b/internal/admin/dashboard/static/js/modules/budgets.test.cjs @@ -0,0 +1,406 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +function loadBudgetsModuleFactory(overrides = {}) { + const source = fs.readFileSync(path.join(__dirname, 'budgets.js'), 'utf8'); + const window = { + ...(overrides.window || {}) + }; + const context = { + console, + setTimeout, + clearTimeout, + ...overrides, + window + }; + vm.createContext(context); + vm.runInContext(source, context); + return context.window.dashboardBudgetsModule; +} + +function createBudgetsModule(overrides) { + const factory = loadBudgetsModuleFactory(overrides); + return factory(); +} + +test('budgetManagementEnabled defaults on and respects the runtime flag', () => { + const module = createBudgetsModule(); + assert.equal(module.budgetManagementEnabled(), true); + + module.workflowRuntimeBooleanFlag = (key, fallback) => { + assert.equal(key, 'BUDGETS_ENABLED'); + assert.equal(fallback, true); + return false; + }; + assert.equal(module.budgetManagementEnabled(), false); +}); + +test('budgetSettingsPayload normalizes numeric values before saving', () => { + const module = createBudgetsModule(); + module.budgetSettings = { + daily_reset_hour: '7', + daily_reset_minute: '15', + weekly_reset_weekday: '3', + weekly_reset_hour: '8', + weekly_reset_minute: '45', + monthly_reset_day: '31', + monthly_reset_hour: '9', + monthly_reset_minute: '30' + }; + + assert.equal(JSON.stringify(module.budgetSettingsPayload()), JSON.stringify({ + daily_reset_hour: 7, + daily_reset_minute: 15, + weekly_reset_weekday: 3, + weekly_reset_hour: 8, + weekly_reset_minute: 45, + monthly_reset_day: 31, + monthly_reset_hour: 9, + monthly_reset_minute: 30 + })); +}); + +test('budgetSettingsPayload falls back for blank and fractional values', () => { + const module = createBudgetsModule(); + module.budgetSettings = { + daily_reset_hour: '', + daily_reset_minute: '15.5', + weekly_reset_weekday: '3', + weekly_reset_hour: '8', + weekly_reset_minute: '45', + monthly_reset_day: '31', + monthly_reset_hour: '9', + monthly_reset_minute: '30' + }; + + const payload = module.budgetSettingsPayload(); + assert.equal(payload.daily_reset_hour, 0); + assert.equal(payload.daily_reset_minute, 0); + assert.equal(payload.weekly_reset_weekday, 3); +}); + +test('budgetFormPayload normalizes user path and standard periods', () => { + const module = createBudgetsModule(); + module.budgetForm = { + user_path: 'team/alpha', + period: 'weekly', + period_seconds: 0, + amount: '12.3456', + source: '' + }; + + assert.equal(JSON.stringify(module.budgetFormPayload()), JSON.stringify({ + user_path: '/team/alpha', + period_seconds: 604800, + amount: 12.3456, + source: 'manual' + })); +}); + +test('setBudgetFormUserPath keeps the form input controlled with a leading slash', () => { + const module = createBudgetsModule(); + + assert.equal(module.defaultBudgetForm().user_path, '/'); + + module.budgetForm = module.defaultBudgetForm(); + module.setBudgetFormUserPath('team/alpha'); + assert.equal(module.budgetForm.user_path, '/team/alpha'); + + module.setBudgetFormUserPath('/platform/service'); + assert.equal(module.budgetForm.user_path, '/platform/service'); + + module.setBudgetFormUserPath(''); + assert.equal(module.budgetForm.user_path, '/'); +}); + +test('fetchBudgets reads budget rows from the list envelope', async () => { + let renderIconsCalls = 0; + const module = createBudgetsModule({ + fetch(url) { + assert.equal(url, '/admin/api/v1/budgets'); + return Promise.resolve({ + status: 200, + ok: true, + json: () => Promise.resolve({ + budgets: [ + { user_path: '/team', period_seconds: 86400, amount: 10 } + ] + }) + }); + } + }); + module.requestOptions = () => ({}); + module.handleFetchResponse = () => true; + module.renderIconsAfterUpdate = () => { + renderIconsCalls++; + }; + + await module.fetchBudgets(); + + assert.equal(module.budgetsAvailable, true); + assert.equal(module.budgets.length, 1); + assert.equal(module.budgets[0].user_path, '/team'); + assert.equal(renderIconsCalls, 1); +}); + +test('submitBudgetForm asks for confirmation before a create overrides an existing budget', async () => { + const module = createBudgetsModule({ + fetch() { + throw new Error('fetch should not be called before override confirmation'); + } + }); + module.budgets = [ + { user_path: '/team', period_seconds: 86400, amount: 10, period_label: 'daily' } + ]; + module.budgetForm = { + user_path: 'team', + period: 'daily', + period_seconds: 0, + amount: '12.5', + source: 'manual' + }; + + await module.submitBudgetForm(); + + assert.equal(module.budgetOverrideDialogOpen, true); + assert.equal(module.budgetFormSubmitting, false); + assert.equal(module.budgetOverrideExistingBudget.user_path, '/team'); + assert.equal(JSON.stringify(module.budgetOverridePendingPayload), JSON.stringify({ + user_path: '/team', + period_seconds: 86400, + amount: 12.5, + source: 'manual' + })); + assert.match(module.budgetOverrideDialogMessage(), /A budget for "\/team Daily" already exists/); + assert.match(module.budgetOverrideDialogMessage(), /override the current \$10\.0000 limit with \$12\.5000/); +}); + +test('confirmBudgetOverride saves the pending create after confirmation', async () => { + const requests = []; + const module = createBudgetsModule({ + fetch(url, request) { + requests.push({ url, request }); + return Promise.resolve({ + status: 200, + ok: true, + json: () => Promise.resolve({ + budgets: [ + { user_path: '/team', period_seconds: 86400, amount: 12.5 } + ] + }) + }); + } + }); + module.requestOptions = (options) => options || {}; + module.handleFetchResponse = () => true; + module.budgetFormOpen = true; + module.budgetOverrideDialogOpen = true; + module.budgetOverrideExistingBudget = { user_path: '/team', period_seconds: 86400, amount: 10 }; + module.budgetOverridePendingPayload = { + user_path: '/team', + period_seconds: 86400, + amount: 12.5, + source: 'manual' + }; + + await module.confirmBudgetOverride(); + + assert.equal(requests.length, 2); + assert.equal(requests[0].url, '/admin/api/v1/budgets/%2Fteam/86400'); + assert.equal(requests[0].request.method, 'PUT'); + assert.equal(requests[0].request.body, JSON.stringify({ + amount: 12.5 + })); + assert.equal(requests[1].url, '/admin/api/v1/budgets'); + assert.equal(module.budgetOverrideDialogOpen, false); + assert.equal(module.budgetFormOpen, false); + assert.equal(module.budgetNotice, 'Budget saved.'); + assert.equal(JSON.stringify(module.budgets), JSON.stringify([ + { user_path: '/team', period_seconds: 86400, amount: 12.5 } + ])); +}); + +test('filteredBudgets filters by user path or period and applies selected sorting', () => { + const module = createBudgetsModule(); + module.budgets = [ + { user_path: '/team/beta', period_seconds: 604800, period_label: 'weekly' }, + { user_path: '/team/alpha', period_seconds: 86400, period_label: 'daily' }, + { user_path: '/platform', period_seconds: 2592000, period_label: 'monthly' }, + { user_path: '/team/alpha', period_seconds: 2592000, period_label: 'monthly' }, + { user_path: '/experiments', period_seconds: 777777, period_label: '777777s' } + ]; + + module.budgetFilter = 'TEAM/A'; + assert.equal(JSON.stringify(module.filteredBudgets()), JSON.stringify([ + { user_path: '/team/alpha', period_seconds: 2592000, period_label: 'monthly' }, + { user_path: '/team/alpha', period_seconds: 86400, period_label: 'daily' } + ])); + + module.budgetFilter = 'weekly'; + assert.equal(JSON.stringify(module.filteredBudgets()), JSON.stringify([ + { user_path: '/team/beta', period_seconds: 604800, period_label: 'weekly' } + ])); + + module.budgetFilter = 'custom'; + assert.equal(JSON.stringify(module.filteredBudgets()), JSON.stringify([ + { user_path: '/experiments', period_seconds: 777777, period_label: '777777s' } + ])); + + module.budgetFilter = ''; + assert.equal(JSON.stringify(module.filteredBudgets()), JSON.stringify([ + { user_path: '/experiments', period_seconds: 777777, period_label: '777777s' }, + { user_path: '/platform', period_seconds: 2592000, period_label: 'monthly' }, + { user_path: '/team/alpha', period_seconds: 2592000, period_label: 'monthly' }, + { user_path: '/team/alpha', period_seconds: 86400, period_label: 'daily' }, + { user_path: '/team/beta', period_seconds: 604800, period_label: 'weekly' } + ])); + + module.budgetSortBy = 'period'; + assert.equal(JSON.stringify(module.filteredBudgets()), JSON.stringify([ + { user_path: '/platform', period_seconds: 2592000, period_label: 'monthly' }, + { user_path: '/team/alpha', period_seconds: 2592000, period_label: 'monthly' }, + { user_path: '/experiments', period_seconds: 777777, period_label: '777777s' }, + { user_path: '/team/beta', period_seconds: 604800, period_label: 'weekly' }, + { user_path: '/team/alpha', period_seconds: 86400, period_label: 'daily' } + ])); +}); + +test('budgetSourceTitle explains manual and config sources', () => { + const module = createBudgetsModule(); + + assert.equal(module.budgetSourceTitle({ source: 'manual' }), 'Created from the dashboard.'); + assert.equal(module.budgetSourceTitle({ source: 'config' }), 'Loaded from configuration.'); + assert.equal(module.budgetSourceTitle({ source: 'import' }), 'Budget source: import'); +}); + +test('budgetPeriodLabel and class distinguish standard and custom periods', () => { + const module = createBudgetsModule(); + + assert.equal(module.budgetPeriodLabel({ period_seconds: 3600 }), 'Hourly'); + assert.equal(module.budgetPeriodClass({ period_seconds: 3600 }), 'budget-period-label-hourly'); + assert.equal(module.budgetPeriodBarClass({ period_seconds: 3600 }), 'budget-bar-fill-period-hourly'); + assert.equal(module.budgetPeriodTrackClass({ period_seconds: 3600 }), 'budget-bar-track-period-hourly'); + assert.equal(module.budgetPeriodIcon({ period_seconds: 3600 }), 'clock'); + assert.equal(module.budgetPeriodDurationLabel({ period_seconds: 3600 }), '1 hour'); + assert.equal(module.budgetPeriodLabel({ period_seconds: 86400 }), 'Daily'); + assert.equal(module.budgetPeriodClass({ period_seconds: 86400 }), 'budget-period-label-daily'); + assert.equal(module.budgetPeriodBarClass({ period_seconds: 86400 }), 'budget-bar-fill-period-daily'); + assert.equal(module.budgetPeriodTrackClass({ period_seconds: 86400 }), 'budget-bar-track-period-daily'); + assert.equal(module.budgetPeriodIcon({ period_seconds: 86400 }), 'sun'); + assert.equal(module.budgetPeriodDurationLabel({ period_seconds: 86400 }), '1 day'); + assert.equal(module.budgetPeriodLabel({ period_seconds: 604800 }), 'Weekly'); + assert.equal(module.budgetPeriodClass({ period_seconds: 604800 }), 'budget-period-label-weekly'); + assert.equal(module.budgetPeriodBarClass({ period_seconds: 604800 }), 'budget-bar-fill-period-weekly'); + assert.equal(module.budgetPeriodTrackClass({ period_seconds: 604800 }), 'budget-bar-track-period-weekly'); + assert.equal(module.budgetPeriodIcon({ period_seconds: 604800 }), 'calendar-days'); + assert.equal(module.budgetPeriodDurationLabel({ period_seconds: 604800 }), '1 week'); + assert.equal(module.budgetPeriodLabel({ period_seconds: 2592000 }), 'Monthly'); + assert.equal(module.budgetPeriodClass({ period_seconds: 2592000 }), 'budget-period-label-monthly'); + assert.equal(module.budgetPeriodBarClass({ period_seconds: 2592000 }), 'budget-bar-fill-period-monthly'); + assert.equal(module.budgetPeriodTrackClass({ period_seconds: 2592000 }), 'budget-bar-track-period-monthly'); + assert.equal(module.budgetPeriodIcon({ period_seconds: 2592000 }), 'calendar'); + assert.equal(module.budgetPeriodDurationLabel({ period_seconds: 2592000 }), '1 month'); + assert.equal(module.budgetPeriodLabel({ period_seconds: 7200, period_label: '7200s' }), 'Custom 7200s'); + assert.equal(module.budgetPeriodClass({ period_seconds: 7200 }), 'budget-period-label-custom'); + assert.equal(module.budgetPeriodBarClass({ period_seconds: 7200 }), 'budget-bar-fill-period-custom'); + assert.equal(module.budgetPeriodTrackClass({ period_seconds: 7200 }), 'budget-bar-track-period-custom'); + assert.equal(module.budgetPeriodIcon({ period_seconds: 7200 }), 'settings-2'); + assert.equal(module.budgetPeriodDurationLabel({ period_seconds: 7200 }), '7200 seconds'); + assert.equal(module.budgetPeriodDurationLabel({ period_seconds: 1 }), '1 second'); +}); + +test('deleteBudget uses the selected budget key in the URL and refreshes from the response envelope', async () => { + const requests = []; + const module = createBudgetsModule({ + confirm(message) { + assert.match(message, /Delete budget "\/team Daily"\? This cannot be undone\./); + return true; + }, + fetch(url, request) { + requests.push({ url, request }); + return Promise.resolve({ + status: 200, + ok: true, + json: () => Promise.resolve({ + budgets: [ + { user_path: '/other', period_seconds: 86400, amount: 5 } + ] + }) + }); + } + }); + module.requestOptions = (options) => options || {}; + module.handleFetchResponse = () => true; + + await module.deleteBudget({ user_path: '/team', period_seconds: 86400, period_label: 'daily' }); + + assert.equal(requests.length, 1); + assert.equal(requests[0].url, '/admin/api/v1/budgets/%2Fteam/86400'); + assert.equal(requests[0].request.method, 'DELETE'); + assert.equal(requests[0].request.body, undefined); + assert.equal(module.budgetDeletingKey, ''); + assert.equal(module.budgetNotice, 'Budget deleted.'); + assert.equal(JSON.stringify(module.budgets), JSON.stringify([ + { user_path: '/other', period_seconds: 86400, amount: 5 } + ])); +}); + +test('resetBudgets requires the typed reset confirmation before posting', async () => { + const module = createBudgetsModule({ + fetch() { + throw new Error('fetch should not be called'); + } + }); + module.requestOptions = (options) => options || {}; + module.handleFetchResponse = () => true; + module.budgetResetConfirmation = 'nope'; + + await module.resetBudgets(); + + assert.equal(module.budgetSettingsError, 'Type reset to confirm.'); + assert.equal(module.budgetResetLoading, false); +}); + +test('resetBudgets posts after confirmation and refreshes the budget page', async () => { + const requests = []; + let refreshCalls = 0; + const module = createBudgetsModule({ + fetch(url, request) { + return module.fetch(url, request); + } + }); + module.fetch = (url, request) => { + requests.push({ url, request }); + assert.equal(url, '/admin/api/v1/budgets/reset'); + assert.equal(request.method, 'POST'); + return Promise.resolve({ status: 200, ok: true }); + }; + module.requestOptions = (options) => options || {}; + module.handleFetchResponse = (res, label, request) => { + assert.equal(label, 'budget reset'); + assert.equal(res.ok, true); + assert.equal(request.method, 'POST'); + return true; + }; + module.page = 'budgets'; + module.budgetResetDialogOpen = true; + module.budgetResetConfirmation = 'reset'; + module.fetchBudgets = async () => { + refreshCalls++; + module.budgets = [{ user_path: '/', period_seconds: 86400, amount: 1 }]; + }; + + await module.resetBudgets(); + + assert.equal(module.budgetSettingsError, ''); + assert.equal(module.budgetResetLoading, false); + assert.equal(requests.length, 1); + assert.equal(requests[0].request.body, JSON.stringify({ confirmation: 'reset' })); + assert.equal(refreshCalls, 1); + assert.equal(module.budgetResetDialogOpen, false); + assert.equal(module.budgetSettingsNotice, 'Budgets reset.'); + assert.deepEqual(module.budgets, [{ user_path: '/', period_seconds: 86400, amount: 1 }]); +}); diff --git a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs index 8e9d62a0..aa5e25d5 100644 --- a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs +++ b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs @@ -19,6 +19,7 @@ function readDashboardTemplateSource() { readFixture("../../../templates/index.html"), readFixture("../../../templates/page-overview.html"), readFixture("../../../templates/page-usage.html"), + readFixture("../../../templates/page-budgets.html"), readFixture("../../../templates/page-settings.html"), readFixture("../../../templates/page-guardrails.html"), readFixture("../../../templates/page-auth-keys.html"), @@ -62,7 +63,7 @@ test("sidebar and main content share the flex layout without manual content offs assert.doesNotMatch(template, /content-collapsed/); assert.match( template, - /href="{{appURL "\/admin\/dashboard\/overview"}}"[\s\S]*Overview<\/span>[\s\S]*href="{{appURL "\/admin\/dashboard\/models"}}"[\s\S]*Models<\/span>[\s\S]*href="{{appURL "\/admin\/dashboard\/audit-logs"}}"[\s\S]*Audit Logs<\/span>[\s\S]*href="{{appURL "\/admin\/dashboard\/usage"}}"[\s\S]*Usage<\/span>[\s\S]*href="{{appURL "\/admin\/dashboard\/auth-keys"}}"[\s\S]*API Keys<\/span>[\s\S]*href="{{appURL "\/admin\/dashboard\/workflows"}}"[\s\S]*Workflows<\/span>[\s\S]*href="{{appURL "\/admin\/dashboard\/guardrails"}}"[\s\S]*x-show="guardrailsPageVisible\(\)"[\s\S]*Guardrails \(experimental\)<\/span>[\s\S]*href="{{appURL "\/admin\/dashboard\/settings"}}"[\s\S]*Settings<\/span>/, + /href="{{appURL "\/admin\/dashboard\/overview"}}"[\s\S]*Overview<\/span>[\s\S]*href="{{appURL "\/admin\/dashboard\/models"}}"[\s\S]*Models<\/span>[\s\S]*href="{{appURL "\/admin\/dashboard\/audit-logs"}}"[\s\S]*Audit Logs<\/span>[\s\S]*href="{{appURL "\/admin\/dashboard\/usage"}}"[\s\S]*Usage<\/span>[\s\S]*href="{{appURL "\/admin\/dashboard\/budgets"}}"[\s\S]*x-show="budgetManagementEnabled\(\)"[\s\S]*Budgets<\/span>[\s\S]*href="{{appURL "\/admin\/dashboard\/auth-keys"}}"[\s\S]*API Keys<\/span>[\s\S]*href="{{appURL "\/admin\/dashboard\/workflows"}}"[\s\S]*Workflows<\/span>[\s\S]*href="{{appURL "\/admin\/dashboard\/guardrails"}}"[\s\S]*x-show="guardrailsPageVisible\(\)"[\s\S]*Guardrails \(experimental\)<\/span>[\s\S]*href="{{appURL "\/admin\/dashboard\/settings"}}"[\s\S]*Settings<\/span>/, ); const sidebarRule = readCSSRule(css, ".sidebar"); @@ -88,6 +89,9 @@ test("sidebar and main content share the flex layout without manual content offs const collapsedSidebarRule = readCSSRule(css, ".sidebar.sidebar-collapsed"); assert.match(collapsedSidebarRule, /flex-basis:\s*60px/); + + const sidebarLogoRule = readCSSRule(css, ".sidebar-logo"); + assert.match(sidebarLogoRule, /color:\s*var\(--accent\)/); }); test("sidebar theme controls and collapse handle stay keyboard accessible", () => { @@ -178,6 +182,10 @@ test("dashboard chrome uses Lucide icons for stable navigation and auth controls template, /data-lucide="chart-column" class="nav-icon"[\s\S]*Usage<\/span>/, ); + assert.match( + template, + /data-lucide="wallet" class="nav-icon"[\s\S]*Budgets<\/span>/, + ); assert.match( template, /data-lucide="key-round" class="nav-icon"[\s\S]*API Keys<\/span>/, @@ -300,7 +308,7 @@ test("dashboard pages reuse a shared auth banner template", () => { const authBannerCalls = indexTemplate.match(/{{template "auth-banner" \.}}/g) || []; - assert.equal(authBannerCalls.length, 8); + assert.equal(authBannerCalls.length, 9); assert.match( indexTemplate, /