From a9a6463f71287c2d2477fe03919eff55222064b4 Mon Sep 17 00:00:00 2001 From: Goon Date: Sat, 23 May 2026 08:51:04 +0700 Subject: [PATCH] feat(usage): bridge agent monthly budget caps --- docs/02-providers.md | 2 + docs/06-store-data-model.md | 6 +- docs/18-http-api.md | 2 + docs/project-changelog.md | 1 + internal/gateway/methods/agents_update.go | 8 +- internal/http/usage_caps.go | 9 ++ internal/store/pg/agents.go | 95 ++++++++++++++++++ .../pg/agents_update_null_coerce_test.go | 96 +++++++++++++++++++ internal/store/pg/usage_caps.go | 38 ++++++-- internal/store/usage_caps.go | 9 +- internal/upgrade/version.go | 2 +- ...071_agent_budget_usage_cap_bridge.down.sql | 7 ++ ...00071_agent_budget_usage_cap_bridge.up.sql | 23 +++++ ui/web/src/i18n/locales/en/usage.json | 2 + ui/web/src/i18n/locales/vi/usage.json | 2 + ui/web/src/i18n/locales/zh/usage.json | 2 + .../usage/components/usage-caps-panel.tsx | 8 +- ui/web/src/types/usage-caps.ts | 1 + 18 files changed, 296 insertions(+), 17 deletions(-) create mode 100644 migrations/000071_agent_budget_usage_cap_bridge.down.sql create mode 100644 migrations/000071_agent_budget_usage_cap_bridge.up.sql diff --git a/docs/02-providers.md b/docs/02-providers.md index 003b2e5a..642464e7 100644 --- a/docs/02-providers.md +++ b/docs/02-providers.md @@ -71,6 +71,8 @@ Runtime flow: Token-only policies do not require catalog pricing. Model fallback routes reserve against the actual candidate provider/model before each attempt. Cached input is separated from uncached input for OpenAI-compatible usage accounting. Partial stream failures keep the estimate, or actual provider usage when available, instead of clearing billed output to zero. Internal LLM calls for memory flush, compaction, media reading tools (`read_image`, `read_document`, `read_audio`, `read_video`), and subagents use the same preflight/reconcile path. +The legacy agent-level `budget_monthly_cents` field is treated as a generated monthly agent USD cap. Existing values are backfilled during migration, and later agent budget edits update or remove the generated cap policy. + Supported price units: input, output, cache read, cache write, reasoning, request, image, and web search. --- diff --git a/docs/06-store-data-model.md b/docs/06-store-data-model.md index fa8b696a..efdac94a 100644 --- a/docs/06-store-data-model.md +++ b/docs/06-store-data-model.md @@ -93,7 +93,7 @@ Usage cap enforcement is Standard/PostgreSQL-only in round one. The `UsageCapSto Tables: - `usage_pricing_catalog`: OpenRouter model catalog prices, raw upstream model payload, sync time. - `usage_pricing_overrides`: tenant/provider/model override prices for custom billing assumptions. -- `usage_cap_policies`: cap definitions scoped by tenant, agent, provider, provider type, model, and `window_key`. +- `usage_cap_policies`: cap definitions scoped by tenant, agent, provider, provider type, model, `window_key`, and `source`. - `usage_cap_counters`: current window used and reserved token/cost counters. - `usage_cap_reservations`: preflight reservations keyed by LLM call attempt. - `usage_cap_events`: allow/block/reconcile/skip audit events. @@ -104,9 +104,11 @@ Policy `agent_id` references must belong to the same tenant as the policy. Polic Catalog and override price fields are nullable decimal strings with non-negative validation in the store layer and database checks. Pricing resolution checks exact override/catalog model IDs first, then provider-derived OpenRouter aliases for native unprefixed model IDs. +Agent `budget_monthly_cents` values are bridged into `usage_cap_policies` with `source = 'agent_budget_monthly_cents'`, an agent scope, `window_key = 'month'`, and `max_cost_micros = budget_monthly_cents * 10000`. Updating or clearing the agent budget keeps that generated policy in sync; manual cap policies continue to use `source = 'manual'`. + Migration versions: -- PostgreSQL: `000069_usage_caps_pricing`, `000070_usage_cap_policies`. +- PostgreSQL: `000069_usage_caps_pricing`, `000070_usage_cap_policies`, `000071_agent_budget_usage_cap_bridge`. - SQLite: no schema change; feature is not active in Lite. --- diff --git a/docs/18-http-api.md b/docs/18-http-api.md index f24f4d41..de1069f5 100644 --- a/docs/18-http-api.md +++ b/docs/18-http-api.md @@ -1294,6 +1294,8 @@ Usage cap policy body: } ``` +Policy responses include read-only `source`. `source="agent_budget_monthly_cents"` means the policy is generated from the agent monthly budget field and must be changed there; direct policy updates/deletes return `409 Conflict`. + --- ## 24. Activity & Audit diff --git a/docs/project-changelog.md b/docs/project-changelog.md index f6b68bdd..b965c81a 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -16,6 +16,7 @@ Significant changes, features, and fixes in reverse chronological order. - Enforced caps in agent, fallback model, subagent, memory flush, compaction, and media reading tools (`read_image`, `read_document`, `read_audio`, `read_video`) with preflight reservation and post-call reconciliation. - Added non-negative validation for catalog and override pricing fields. - Added OpenRouter alias resolution for native model IDs, cached-input accounting normalization, and partial-stream failure reconciliation. +- Bridged legacy `budget_monthly_cents` into generated monthly agent USD cap policies, including migration backfill and save-time sync. - Added web dashboard controls on Usage and Provider detail pages. **Tests** diff --git a/internal/gateway/methods/agents_update.go b/internal/gateway/methods/agents_update.go index e1f96651..d0d76d69 100644 --- a/internal/gateway/methods/agents_update.go +++ b/internal/gateway/methods/agents_update.go @@ -60,6 +60,10 @@ func (m *AgentsMethods) handleUpdate(ctx context.Context, client *gateway.Client if req.Params != nil { json.Unmarshal(req.Params, ¶ms) } + var rawParams map[string]json.RawMessage + if req.Params != nil { + _ = json.Unmarshal(req.Params, &rawParams) + } if params.AgentID == "" { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "agentId"))) @@ -104,7 +108,9 @@ func (m *AgentsMethods) handleUpdate(ctx context.Context, client *gateway.Client if params.IsDefault != nil { updates["is_default"] = *params.IsDefault } - if params.BudgetCents != nil { + if rawBudget, ok := rawParams["budget_monthly_cents"]; ok && strings.TrimSpace(string(rawBudget)) == "null" { + updates["budget_monthly_cents"] = nil + } else if params.BudgetCents != nil { updates["budget_monthly_cents"] = *params.BudgetCents } // Per-agent JSONB config overrides diff --git a/internal/http/usage_caps.go b/internal/http/usage_caps.go index 7e027528..1fc64c8f 100644 --- a/internal/http/usage_caps.go +++ b/internal/http/usage_caps.go @@ -3,6 +3,7 @@ package http import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "net/http" @@ -108,6 +109,10 @@ func (h *UsageCapsHandler) handleUpdatePolicy(w http.ResponseWriter, r *http.Req } p, err := h.store.UpdateUsageCapPolicy(r.Context(), tenantIDOrMaster(r), id, patch) if err != nil { + if errors.Is(err, store.ErrUsageCapPolicyManaged) { + writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) + return + } slog.Warn("usage_caps.update_policy_failed", "error", err) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "usage cap policy validation failed"}) return @@ -122,6 +127,10 @@ func (h *UsageCapsHandler) handleDeletePolicy(w http.ResponseWriter, r *http.Req return } if err := h.store.DeleteUsageCapPolicy(r.Context(), tenantIDOrMaster(r), id); err != nil { + if errors.Is(err, store.ErrUsageCapPolicyManaged) { + writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) + return + } writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "delete failed"}) return } diff --git a/internal/store/pg/agents.go b/internal/store/pg/agents.go index 234cf1a6..e5a19437 100644 --- a/internal/store/pg/agents.go +++ b/internal/store/pg/agents.go @@ -133,6 +133,12 @@ func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error if err != nil { return err } + if agent.BudgetMonthlyCents != nil { + if err := s.syncAgentBudgetUsageCap(ctx, tenantID, agent.ID, agent.BudgetMonthlyCents); err != nil { + _, _ = s.db.ExecContext(ctx, "DELETE FROM agents WHERE id = $1", agent.ID) + return err + } + } // Generate embedding for new agent with frontmatter if agent.Frontmatter != "" && s.embProvider != nil { @@ -189,6 +195,22 @@ func (s *PGAgentStore) Update(ctx context.Context, id uuid.UUID, updates map[str if len(updates) == 0 { return nil } + var err error + var budgetCents *int + rawBudget, syncBudget := updates["budget_monthly_cents"] + if syncBudget { + budgetCents, err = budgetCentsFromUpdate(rawBudget) + if err != nil { + return err + } + } + var budgetTenantID uuid.UUID + if syncBudget { + budgetTenantID, err = s.agentTenantID(ctx, id) + if err != nil { + return err + } + } // Coerce NOT NULL columns: null → default to prevent constraint violations. // Promoted TEXT columns (migration 000037): null → empty string. @@ -248,6 +270,11 @@ func (s *PGAgentStore) Update(ctx context.Context, id uuid.UUID, updates map[str return err } } + if syncBudget { + if err := s.syncAgentBudgetUsageCap(ctx, budgetTenantID, id, budgetCents); err != nil { + return err + } + } // Regenerate embedding when frontmatter changes if _, hasFrontmatter := updates["frontmatter"]; hasFrontmatter && s.embProvider != nil { @@ -262,6 +289,74 @@ func (s *PGAgentStore) Update(ctx context.Context, id uuid.UUID, updates map[str return nil } +func (s *PGAgentStore) agentTenantID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) { + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + return tid, nil + } + } + var tenantID uuid.UUID + if err := s.db.QueryRowContext(ctx, "SELECT tenant_id FROM agents WHERE id = $1 AND deleted_at IS NULL", id).Scan(&tenantID); err != nil { + return uuid.Nil, fmt.Errorf("agent not found: %s", id) + } + return tenantID, nil +} + +func budgetCentsFromUpdate(v any) (*int, error) { + if v == nil { + return nil, nil + } + var cents int + switch n := v.(type) { + case int: + cents = n + case int32: + cents = int(n) + case int64: + cents = int(n) + case float64: + if n != float64(int(n)) { + return nil, fmt.Errorf("budget_monthly_cents must be an integer") + } + cents = int(n) + default: + return nil, fmt.Errorf("budget_monthly_cents must be an integer") + } + if cents < 0 { + return nil, fmt.Errorf("budget_monthly_cents must be non-negative") + } + return ¢s, nil +} + +func (s *PGAgentStore) syncAgentBudgetUsageCap(ctx context.Context, tenantID, agentID uuid.UUID, budgetCents *int) error { + if budgetCents == nil || *budgetCents <= 0 { + _, err := s.db.ExecContext(ctx, + `DELETE FROM usage_cap_policies + WHERE tenant_id=$1 AND agent_id=$2 AND source=$3`, + tenantID, agentID, store.UsageCapSourceAgentBudget) + return err + } + costMicros := int64(*budgetCents) * 10000 + _, err := s.db.ExecContext(ctx, ` +INSERT INTO usage_cap_policies ( + tenant_id, agent_id, window_key, max_cost_micros, enabled, priority, source +) VALUES ($1,$2,'month',$3,true,90,$4) +ON CONFLICT (tenant_id, agent_id) WHERE source = 'agent_budget_monthly_cents' +DO UPDATE SET + window_key='month', + provider_id=NULL, + provider_type=NULL, + model_id=NULL, + max_tokens=NULL, + max_cost_micros=EXCLUDED.max_cost_micros, + enabled=true, + priority=EXCLUDED.priority, + updated_at=now()`, + tenantID, agentID, costMicros, store.UsageCapSourceAgentBudget) + return err +} + func isEmptyOrNullJSONUpdate(v any) bool { if v == nil { return true diff --git a/internal/store/pg/agents_update_null_coerce_test.go b/internal/store/pg/agents_update_null_coerce_test.go index 3148b68e..5782d545 100644 --- a/internal/store/pg/agents_update_null_coerce_test.go +++ b/internal/store/pg/agents_update_null_coerce_test.go @@ -1,8 +1,14 @@ package pg import ( + "context" + "database/sql" "encoding/json" + "errors" "testing" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/store" ) func TestIsEmptyOrNullJSONUpdate(t *testing.T) { @@ -30,3 +36,93 @@ func TestIsEmptyOrNullJSONUpdate(t *testing.T) { }) } } + +func TestPGAgentStoreSyncsMonthlyBudgetUsageCap(t *testing.T) { + db := hooksTestDB(t) + tenantID, _ := seedTenantAndAgent(t, db) + ctx := tenantScopedCtx(tenantID) + agentStore := NewPGAgentStore(db) + budgetCents := 123 + agentID := uuid.New() + agent := &store.AgentData{ + BaseModel: store.BaseModel{ID: agentID}, + TenantID: tenantID, AgentKey: "budget-agent-" + agentID.String(), + OwnerID: "owner", Provider: "openai", Model: "gpt-test", + AgentType: store.AgentTypePredefined, Status: store.AgentStatusActive, + BudgetMonthlyCents: &budgetCents, + } + if err := agentStore.Create(ctx, agent); err != nil { + t.Fatalf("Create: %v", err) + } + assertAgentBudgetPolicy(t, db, tenantID, agentID, 1230000) + var generatedPolicyID uuid.UUID + if err := db.QueryRowContext(context.Background(), + `SELECT id FROM usage_cap_policies + WHERE tenant_id=$1 AND agent_id=$2 AND source=$3`, + tenantID, agentID, store.UsageCapSourceAgentBudget).Scan(&generatedPolicyID); err != nil { + t.Fatalf("query generated policy id: %v", err) + } + if err := NewPGUsageCapStore(db).DeleteUsageCapPolicy(ctx, tenantID, generatedPolicyID); err == nil { + t.Fatal("DeleteUsageCapPolicy deleted generated agent budget policy") + } else if !errors.Is(err, store.ErrUsageCapPolicyManaged) { + t.Fatalf("DeleteUsageCapPolicy error = %v, want managed policy error", err) + } + disablePolicy := false + if _, err := NewPGUsageCapStore(db).UpdateUsageCapPolicy(ctx, tenantID, generatedPolicyID, store.UsageCapPolicyPatch{ + Enabled: &disablePolicy, + }); err == nil { + t.Fatal("UpdateUsageCapPolicy updated generated agent budget policy") + } else if !errors.Is(err, store.ErrUsageCapPolicyManaged) { + t.Fatalf("UpdateUsageCapPolicy error = %v, want managed policy error", err) + } + assertAgentBudgetPolicy(t, db, tenantID, agentID, 1230000) + + if err := agentStore.Update(ctx, agentID, map[string]any{"budget_monthly_cents": float64(456)}); err != nil { + t.Fatalf("Update budget: %v", err) + } + assertAgentBudgetPolicy(t, db, tenantID, agentID, 4560000) + + if err := agentStore.Update(ctx, agentID, map[string]any{"budget_monthly_cents": nil}); err != nil { + t.Fatalf("Clear budget: %v", err) + } + var count int + if err := db.QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM usage_cap_policies + WHERE tenant_id=$1 AND agent_id=$2 AND source=$3`, + tenantID, agentID, store.UsageCapSourceAgentBudget).Scan(&count); err != nil { + t.Fatalf("count cleared policy: %v", err) + } + if count != 0 { + t.Fatalf("agent budget policy count after clear = %d, want 0", count) + } +} + +func assertAgentBudgetPolicy(t *testing.T, db rowQueryer, tenantID, agentID uuid.UUID, wantCostMicros int64) { + t.Helper() + var windowKey, source string + var costMicros int64 + var count int + if err := db.QueryRowContext(context.Background(), + `SELECT COUNT(*), COALESCE(MAX(window_key), ''), COALESCE(MAX(source), ''), COALESCE(MAX(max_cost_micros), 0) + FROM usage_cap_policies + WHERE tenant_id=$1 AND agent_id=$2 AND source=$3`, + tenantID, agentID, store.UsageCapSourceAgentBudget).Scan(&count, &windowKey, &source, &costMicros); err != nil { + t.Fatalf("query agent budget policy: %v", err) + } + if count != 1 { + t.Fatalf("agent budget policy count = %d, want 1", count) + } + if windowKey != store.UsageCapWindowMonth { + t.Fatalf("window_key = %q, want month", windowKey) + } + if source != store.UsageCapSourceAgentBudget { + t.Fatalf("source = %q, want %q", source, store.UsageCapSourceAgentBudget) + } + if costMicros != wantCostMicros { + t.Fatalf("max_cost_micros = %d, want %d", costMicros, wantCostMicros) + } +} + +type rowQueryer interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +} diff --git a/internal/store/pg/usage_caps.go b/internal/store/pg/usage_caps.go index 4e8f8e45..06bdeaa4 100644 --- a/internal/store/pg/usage_caps.go +++ b/internal/store/pg/usage_caps.go @@ -20,16 +20,19 @@ func (s *PGUsageCapStore) CreateUsageCapPolicy(ctx context.Context, p *store.Usa if err := s.validateUsageCapRefs(ctx, p.TenantID, p.AgentID, p.ProviderID); err != nil { return err } + if p.Source == "" { + p.Source = store.UsageCapSourceManual + } const q = ` -INSERT INTO usage_cap_policies ( - id, tenant_id, agent_id, provider_id, provider_type, model_id, window_key, - max_tokens, max_cost_micros, enabled, priority -) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) -RETURNING created_at, updated_at` + INSERT INTO usage_cap_policies ( + id, tenant_id, agent_id, provider_id, provider_type, model_id, window_key, + max_tokens, max_cost_micros, source, enabled, priority + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + RETURNING created_at, updated_at` return s.db.QueryRowContext(ctx, q, p.ID, p.TenantID, uuidPtrVal(p.AgentID), uuidPtrVal(p.ProviderID), nullEmpty(p.ProviderType), nullEmpty(p.ModelID), p.Window, - intPtrVal(p.MaxTokens), intPtrVal(p.MaxCostMicros), p.Enabled, p.Priority, + intPtrVal(p.MaxTokens), intPtrVal(p.MaxCostMicros), p.Source, p.Enabled, p.Priority, ).Scan(&p.CreatedAt, &p.UpdatedAt) } @@ -84,6 +87,9 @@ func (s *PGUsageCapStore) UpdateUsageCapPolicy(ctx context.Context, tenantID, id if err != nil { return nil, err } + if p.Source == store.UsageCapSourceAgentBudget { + return nil, fmt.Errorf("%w: agent monthly budget", store.ErrUsageCapPolicyManaged) + } if patch.AgentID != nil { p.AgentID = *patch.AgentID } @@ -129,8 +135,20 @@ RETURNING updated_at` } func (s *PGUsageCapStore) DeleteUsageCapPolicy(ctx context.Context, tenantID, id uuid.UUID) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM usage_cap_policies WHERE tenant_id=$1 AND id=$2`, tenantID, id) - return err + res, err := s.db.ExecContext(ctx, + `DELETE FROM usage_cap_policies + WHERE tenant_id=$1 AND id=$2 AND COALESCE(source,'manual') <> $3`, + tenantID, id, store.UsageCapSourceAgentBudget) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + if _, err := s.getPolicy(ctx, tenantID, id); err == nil { + return fmt.Errorf("%w: agent monthly budget", store.ErrUsageCapPolicyManaged) + } + return sql.ErrNoRows + } + return nil } func (s *PGUsageCapStore) ReserveUsage(ctx context.Context, req store.UsageReserveRequest, policies []store.UsageCapPolicy) (*store.UsageReservationResult, error) { @@ -339,14 +357,14 @@ SELECT EXISTS ( } const policySelectSQL = `SELECT id, tenant_id, agent_id, provider_id, COALESCE(provider_type,''), COALESCE(model_id,''), - window_key, max_tokens, max_cost_micros, enabled, priority, created_at, updated_at FROM usage_cap_policies` + window_key, max_tokens, max_cost_micros, COALESCE(source,'manual'), enabled, priority, created_at, updated_at FROM usage_cap_policies` func scanPolicy(row scanner) (store.UsageCapPolicy, error) { var p store.UsageCapPolicy var agentID, providerID uuid.NullUUID var maxTokens, maxCost sql.NullInt64 err := row.Scan(&p.ID, &p.TenantID, &agentID, &providerID, &p.ProviderType, &p.ModelID, - &p.Window, &maxTokens, &maxCost, &p.Enabled, &p.Priority, &p.CreatedAt, &p.UpdatedAt) + &p.Window, &maxTokens, &maxCost, &p.Source, &p.Enabled, &p.Priority, &p.CreatedAt, &p.UpdatedAt) if agentID.Valid { p.AgentID = &agentID.UUID } diff --git a/internal/store/usage_caps.go b/internal/store/usage_caps.go index 1c123690..6848a4c6 100644 --- a/internal/store/usage_caps.go +++ b/internal/store/usage_caps.go @@ -19,9 +19,15 @@ const ( UsageCapEventBlock = "block" UsageCapEventReconcile = "reconcile" UsageCapEventSkip = "skip" + + UsageCapSourceManual = "manual" + UsageCapSourceAgentBudget = "agent_budget_monthly_cents" ) -var ErrUsageCapExceeded = errors.New("usage cap exceeded") +var ( + ErrUsageCapExceeded = errors.New("usage cap exceeded") + ErrUsageCapPolicyManaged = errors.New("usage cap policy is managed by another setting") +) type UsageCapExceededError struct { PolicyID uuid.UUID @@ -101,6 +107,7 @@ type UsageCapPolicy struct { Window string `json:"window" db:"window"` MaxTokens *int64 `json:"max_tokens,omitempty" db:"max_tokens"` MaxCostMicros *int64 `json:"max_cost_micros,omitempty" db:"max_cost_micros"` + Source string `json:"source,omitempty" db:"source"` Enabled bool `json:"enabled" db:"enabled"` Priority int `json:"priority" db:"priority"` CreatedAt time.Time `json:"created_at" db:"created_at"` diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index ad21b8a5..3ce25186 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -2,4 +2,4 @@ package upgrade // RequiredSchemaVersion is the schema migration version this binary requires. // Bump this whenever adding a new SQL migration file. -const RequiredSchemaVersion uint = 70 +const RequiredSchemaVersion uint = 71 diff --git a/migrations/000071_agent_budget_usage_cap_bridge.down.sql b/migrations/000071_agent_budget_usage_cap_bridge.down.sql new file mode 100644 index 00000000..e26c4b4a --- /dev/null +++ b/migrations/000071_agent_budget_usage_cap_bridge.down.sql @@ -0,0 +1,7 @@ +DELETE FROM usage_cap_policies +WHERE source = 'agent_budget_monthly_cents'; + +DROP INDEX IF EXISTS idx_usage_cap_policies_agent_budget_source; + +ALTER TABLE usage_cap_policies + DROP COLUMN IF EXISTS source; diff --git a/migrations/000071_agent_budget_usage_cap_bridge.up.sql b/migrations/000071_agent_budget_usage_cap_bridge.up.sql new file mode 100644 index 00000000..700d1b9d --- /dev/null +++ b/migrations/000071_agent_budget_usage_cap_bridge.up.sql @@ -0,0 +1,23 @@ +ALTER TABLE usage_cap_policies + ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'manual'; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_usage_cap_policies_agent_budget_source + ON usage_cap_policies (tenant_id, agent_id) + WHERE source = 'agent_budget_monthly_cents'; + +INSERT INTO usage_cap_policies ( + tenant_id, agent_id, window_key, max_cost_micros, enabled, priority, source +) +SELECT + tenant_id, + id, + 'month', + budget_monthly_cents::BIGINT * 10000, + true, + 90, + 'agent_budget_monthly_cents' +FROM agents +WHERE deleted_at IS NULL + AND budget_monthly_cents IS NOT NULL + AND budget_monthly_cents > 0 +ON CONFLICT DO NOTHING; diff --git a/ui/web/src/i18n/locales/en/usage.json b/ui/web/src/i18n/locales/en/usage.json index cc82afce..70a45e09 100644 --- a/ui/web/src/i18n/locales/en/usage.json +++ b/ui/web/src/i18n/locales/en/usage.json @@ -47,6 +47,8 @@ "tenantScope": "Tenant cap", "tenantScoped": "Tenant scoped", "agentScoped": "Agent scoped", + "agentBudgetSource": "Agent budget", + "agentBudgetManaged": "Clear this from the agent monthly budget field.", "enabled": "Enabled", "disabled": "Disabled", "delete": "Delete cap", diff --git a/ui/web/src/i18n/locales/vi/usage.json b/ui/web/src/i18n/locales/vi/usage.json index a7beab3f..28b72586 100644 --- a/ui/web/src/i18n/locales/vi/usage.json +++ b/ui/web/src/i18n/locales/vi/usage.json @@ -47,6 +47,8 @@ "tenantScope": "Cap tenant", "tenantScoped": "Phạm vi tenant", "agentScoped": "Phạm vi agent", + "agentBudgetSource": "Ngân sách agent", + "agentBudgetManaged": "Xóa giới hạn này từ ô ngân sách hàng tháng của agent.", "enabled": "Đã bật", "disabled": "Đã tắt", "delete": "Xóa cap", diff --git a/ui/web/src/i18n/locales/zh/usage.json b/ui/web/src/i18n/locales/zh/usage.json index 1810eb43..58d6b2f4 100644 --- a/ui/web/src/i18n/locales/zh/usage.json +++ b/ui/web/src/i18n/locales/zh/usage.json @@ -47,6 +47,8 @@ "tenantScope": "租户上限", "tenantScoped": "租户范围", "agentScoped": "Agent范围", + "agentBudgetSource": "Agent预算", + "agentBudgetManaged": "请在 Agent 月度预算字段中清除此上限。", "enabled": "已启用", "disabled": "已禁用", "delete": "删除上限", diff --git a/ui/web/src/pages/usage/components/usage-caps-panel.tsx b/ui/web/src/pages/usage/components/usage-caps-panel.tsx index 6f39de72..2371cf4b 100644 --- a/ui/web/src/pages/usage/components/usage-caps-panel.tsx +++ b/ui/web/src/pages/usage/components/usage-caps-panel.tsx @@ -161,18 +161,22 @@ function UsageCapRow({ row, onDelete }: { row: UsageCapUtilization; onDelete: () const costUsed = row.used_cost_micros + row.reserved_cost_micros; const tokenPct = p.max_tokens ? Math.min(100, Math.round((tokenUsed / p.max_tokens) * 100)) : 0; const costPct = p.max_cost_micros ? Math.min(100, Math.round((costUsed / p.max_cost_micros) * 100)) : 0; + const isAgentBudget = p.source === "agent_budget_monthly_cents"; return (
{p.model_id || p.provider_type || t("caps.tenantScope")}
-
{p.agent_id ? t("caps.agentScoped") : t("caps.tenantScoped")}
+
+ {p.agent_id ? t("caps.agentScoped") : t("caps.tenantScoped")} + {isAgentBudget ? {t("caps.agentBudgetSource")} : null} +
{t(`caps.windows.${p.window}`)} {p.max_tokens ? `${formatTokens(tokenUsed)} / ${formatTokens(p.max_tokens)} (${tokenPct}%)` : "—"} {p.max_cost_micros ? `${formatCost(costUsed / 1_000_000)} / ${formatCost(p.max_cost_micros / 1_000_000)} (${costPct}%)` : "—"} {p.enabled ? t("caps.enabled") : t("caps.disabled")} - + ); diff --git a/ui/web/src/types/usage-caps.ts b/ui/web/src/types/usage-caps.ts index 670687be..882e38ea 100644 --- a/ui/web/src/types/usage-caps.ts +++ b/ui/web/src/types/usage-caps.ts @@ -19,6 +19,7 @@ export interface UsageCapPolicy { window: "hour" | "day" | "week" | "month"; max_tokens?: number; max_cost_micros?: number; + source?: "manual" | "agent_budget_monthly_cents"; enabled: boolean; priority: number; created_at: string;