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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ Full reference: `.env.template` and `config/config.yaml`
- **Tagging:** Every request can be labelled from configured HTTP headers. Rules are managed in the dashboard (Settings → "Tagging based on headers", persisted to the `tagging_settings` store) or declared as infrastructure-as-code under `tagging.headers:` in `config.yaml` / numbered env vars `TAGGING_HEADER_1=X-My-Tags` with optional `TAGGING_HEADER_1_PREFIX` (trimmed from each extracted label only), `TAGGING_HEADER_1_DONOTPASS` (default false: headers are forwarded as-is; true strips the header before provider forwarding on passthrough/realtime routes — translated routes never forward client headers), and `TAGGING_HEADER_1_DELIMITER` (default `,`; one header value can carry several labels). An env entry replaces the whole YAML entry with the same header name (unset companion vars reset fields to defaults rather than inheriting YAML values); declarative entries override admin-store rows and are read-only in the dashboard. Credential-bearing headers (`Authorization`, `Cookie`, API-key headers, …) are rejected as tagging sources. Managed API keys can also carry labels (`labels` on `POST /admin/auth-keys`, replaceable later via `PUT /admin/auth-keys/{id}/labels` where `[]` clears, or API Keys → Create API Key / Edit Labels in the dashboard); every request authenticated with the key gets them, merged and de-duplicated with header-extracted labels. Labels are recorded on usage entries (`labels`) and audit log entries (`data.labels`). The dashboard usage page shows a by-label breakdown (`GET /admin/usage/labels`) and label chips with a label filter on the request log (`label` query param on `GET /admin/usage/log`).
- **Audit logging:** `LOGGING_ENABLED` (true), `LOGGING_LOG_BODIES` (true), `LOGGING_LOG_AUDIO_BODIES` (false: refines `LOGGING_LOG_BODIES` for audio endpoints — base64 audio for both `/v1/audio/speech` output and `/v1/audio/transcriptions` upload (≤8 MB each, else `too_large`) + dashboard playback, plus transcription upload metadata; no effect unless `LOGGING_LOG_BODIES` is on, in which case audio-off records a placeholder), `LOGGING_LOG_HEADERS` (true), `LOGGING_RETENTION_DAYS` (30)
- **Usage tracking:** `USAGE_ENABLED` (true), `ENFORCE_RETURNING_USAGE_DATA` (true), `USAGE_RETENTION_DAYS` (90). Callers can read their own status without admin access via `GET /v1/usage`: usage summary over a date window (`start_date`/`end_date`/`days`, default last 30 days UTC) plus budget and rate-limit statuses, all scoped to the caller's effective user path (managed key binding, else the user-path header).
- **Budgets:** `BUDGETS_ENABLED` (true; no-op until budgets exist, and force-disabled with a warning when `USAGE_ENABLED=false` since spend is read from usage cost records). Every budget has a scope: `user_path` (subtree — a budget on `/team` covers `/team/app` but not `/team-alpha`) or `label` (matches a request label verbatim, no case folding; labels come from tagging headers and managed-key labels, and a request carrying several labels is charged against every matching label budget). Limits are an `amount` per period (`hourly`/`daily`/`weekly`/`monthly`, or a custom `period_seconds` — named periods reset on the calendar anchors configured under Settings → Budget Resets, only custom seconds are fixed windows). A breach returns 429 (`code: budget_exceeded`) with `Retry-After`; a budget with no recorded usage never blocks, and response cache hits return before enforcement. Managed in the dashboard (Budgets page: scope selector) / `/admin/budgets` (GET/PUT/DELETE + `POST .../reset-one`, `POST .../reset`; requests take `scope`+`subject`, with `user_path` as shorthand for user-path budgets), or as infrastructure-as-code under `budgets.{user_paths,labels}:` in `config.yaml` / `SET_BUDGET_<PATH>` env vars (`period=amount` compact syntax or a JSON limit array; `__` separates path segments). Label budgets are YAML/admin-only — labels are matched verbatim and are not env-name safe. Config-sourced budgets are read-only in the dashboard and manual edits win over config seeds. Enforcement evaluates every matching budget in ONE batched store query (`Store.SumSpend`), so a wide match set costs one scan rather than one per budget.
- **Rate limits:** `RATE_LIMITS_ENABLED` (true; no-op until rules exist). Every rule has a scope: `user_path` (consumer control; subtree with ONE shared counter per rule — per-key limits = give each key its own path), `provider` (caps one configured provider instance across all consumers/models), or `model` (subject `openai/gpt-4o` pins one provider's model, bare `gpt-4o` covers it on any provider; matching case-insensitive). Limits: `max_requests`/`max_tokens` per period (`minute`/`hour`/`day`/custom `period_seconds`, sliding window) plus `concurrent` (period_seconds 0: `max_requests` = max in-flight; realtime sessions hold a slot for the session, batch submissions don't — and batch skips provider/model rules since batch files can mix models). Enforcement covers every model endpoint; user-path breaches return 429 (`code: rate_limit_exceeded`) with `Retry-After`, successes carry `x-ratelimit-{limit,remaining,reset}-{requests,tokens}` from the most-constrained matching rule; cache hits bypass. Saturated providers/models are instead routed around: virtual-model load balancing prefers targets with capacity (falling back to the first declared target when all are saturated, so the client gets an honest 429 rather than an unavailable-model error; saturation never affects catalog membership or /v1/models listing), a saturated primary route with configured failover rules skips the primary provider and is served by the sweep (which also skips saturated candidates), and only requests with no viable alternative get 429. Token windows are charged to the provider/model that actually executed (from the usage entry), so accounting stays correct under aliasing/failover. Managed in the dashboard (Rate Limits page: scope selector) / `/admin/rate-limits` (GET/PUT/DELETE + `POST .../reset-one`, `POST .../reset`; requests take `scope`+`subject`, with `user_path` as shorthand for user-path rules), or as infrastructure-as-code under `rate_limits.{user_paths,providers,models}:` in `config.yaml` / `SET_RATE_LIMIT_<PATH>` env vars (`rpm/tpm/rph/tph/rpd/tpd/concurrent=N` compact syntax or a JSON rule array; `__` separates path segments) and `SET_PROVIDER_RATE_LIMIT_<NAME>` (same syntax; suffix underscores become hyphens; model rules are YAML/admin-only). Env replaces the whole YAML entry for the same subject; config-sourced rules are read-only in the dashboard and manual edits win over config seeds, like budgets. Token limits are post-accounted from usage entries, so they require `USAGE_ENABLED=true` (startup warns otherwise) and one request can overshoot a token window. Counters are in-memory per instance (N replicas ≈ N× limit) and reset on restart — budgets remain the durable cross-instance control.
- **Dashboard live logs:**
- `DASHBOARD_LIVE_LOGS_ENABLED` (true): keep enabled for low-latency dashboard previews; set false only when live streams are not needed or memory/socket usage must be minimized. With `LOGGING_LOG_BODIES` also enabled, in-flight streamed responses render chunk-by-chunk in the request log and Interactions drawer (throttled `audit.stream` events, published only while a dashboard is connected; partial bodies are never buffered server-side).
Expand Down
30 changes: 30 additions & 0 deletions cmd/gomodel/docs/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

75 changes: 55 additions & 20 deletions config/budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ type BudgetsConfig struct {

// UserPaths declares budget limits by tracked user path.
UserPaths []BudgetUserPathConfig `yaml:"user_paths"`

// Labels declares budget limits by request label. A request carrying
// several labels is charged against every matching label budget.
//
// Labels have no env-var form: they are matched verbatim and may contain
// characters and casing that env var names cannot express. Declare them
// here or through the admin API.
Labels []BudgetLabelConfig `yaml:"labels"`
}

// BudgetUserPathConfig declares one or more budget limits for a user path.
Expand All @@ -29,6 +37,12 @@ type BudgetUserPathConfig struct {
Limits []BudgetLimitConfig `yaml:"limits"`
}

// BudgetLabelConfig declares one or more budget limits for a request label.
type BudgetLabelConfig struct {
Label string `yaml:"label"`
Limits []BudgetLimitConfig `yaml:"limits"`
}

// BudgetLimitConfig declares one spend limit for a reset period.
// The json tags support the JSON-array form of SET_BUDGET_* env values.
type BudgetLimitConfig struct {
Expand Down Expand Up @@ -133,36 +147,57 @@ func validateBudgetConfig(cfg *BudgetsConfig) error {
}
seen := make(map[string]struct{})
for pathIdx, entry := range cfg.UserPaths {
field := fmt.Sprintf("budgets.user_paths[%d]", pathIdx)
if strings.TrimSpace(entry.Path) == "" {
return fmt.Errorf("budgets.user_paths[%d].path is required", pathIdx)
return fmt.Errorf("%s.path is required", field)
}
normalizedPath, err := core.NormalizeUserPath(entry.Path)
if err != nil {
return fmt.Errorf("budgets.user_paths[%d].path is invalid: %w", pathIdx, err)
return fmt.Errorf("%s.path is invalid: %w", field, err)
}
if normalizedPath == "" {
return fmt.Errorf("budgets.user_paths[%d].path is required", pathIdx)
return fmt.Errorf("%s.path is required", field)
}
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)
if err := validateBudgetLimits(cfg.UserPaths[pathIdx].Limits, field, "user_path", normalizedPath, seen); err != nil {
return err
}
}
for labelIdx, entry := range cfg.Labels {
field := fmt.Sprintf("budgets.labels[%d]", labelIdx)
label := strings.TrimSpace(entry.Label)
if label == "" {
return fmt.Errorf("%s.label is required", field)
}
cfg.Labels[labelIdx].Label = label
if err := validateBudgetLimits(cfg.Labels[labelIdx].Limits, field, "label", label, seen); err != nil {
return err
}
}
return nil
}

// validateBudgetLimits validates and canonicalizes the limits of one budget
// subject in place, rejecting a period declared twice for the same subject.
func validateBudgetLimits(limits []BudgetLimitConfig, field, scope, subject string, seen map[string]struct{}) error {
for limitIdx, limit := range limits {
if math.IsNaN(limit.Amount) || math.IsInf(limit.Amount, 0) || limit.Amount <= 0 {
return fmt.Errorf("%s.limits[%d].amount must be a finite number greater than 0", field, limitIdx)
}
seconds := limit.PeriodSeconds
if seconds <= 0 {
parsed, ok := budgetPeriodSeconds(limit.Period)
if !ok {
return fmt.Errorf("%s.limits[%d].period must be one of hourly, daily, weekly, monthly or period_seconds must be set", field, limitIdx)
}
seen[key] = struct{}{}
seconds = parsed
limits[limitIdx].PeriodSeconds = seconds
}
key := scope + "\x00" + subject + "\x00" + strconv.FormatInt(seconds, 10)
if _, ok := seen[key]; ok {
return fmt.Errorf("duplicate budget for %s %s period %d", scope, subject, seconds)
}
seen[key] = struct{}{}
}
return nil
}
Expand Down
7 changes: 7 additions & 0 deletions config/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,13 @@ budgets:
amount: 10.00
- period: "weekly"
amount: 50.00
labels:
# Request labels are matched verbatim, so they have no env-var form:
# declare label budgets here or in the dashboard.
- label: "Mobile-App-iOS"
limits:
- period: "monthly"
amount: 500.00

rate_limits:
enabled: true # env: RATE_LIMITS_ENABLED; with no configured rules this has no effect
Expand Down
2 changes: 1 addition & 1 deletion config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ budgets:
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") {
if !strings.Contains(err.Error(), "duplicate budget for user_path /team/alpha period 86400") {
t.Fatalf("Load() error = %v, want duplicate budget validation", err)
}
})
Expand Down
Loading