mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-16 10:15:12 +00:00
feat(usage): bridge agent monthly budget caps
This commit is contained in:
1 parent
3e29b4f019
commit
a9a6463f71
18 files changed
+296
-17
No files matched your search
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
"tenantScope": "租户上限",
|
||||
"tenantScoped": "租户范围",
|
||||
"agentScoped": "Agent范围",
|
||||
"agentBudgetSource": "Agent预算",
|
||||
"agentBudgetManaged": "请在 Agent 月度预算字段中清除此上限。",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"delete": "删除上限",
|
||||
|
||||
@@ -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 (
|
||||
<tr className="border-b last:border-0">
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium">{p.model_id || p.provider_type || t("caps.tenantScope")}</div>
|
||||
<div className="text-xs text-muted-foreground">{p.agent_id ? t("caps.agentScoped") : t("caps.tenantScoped")}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{p.agent_id ? t("caps.agentScoped") : t("caps.tenantScoped")}</span>
|
||||
{isAgentBudget ? <Badge variant="secondary">{t("caps.agentBudgetSource")}</Badge> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2"><Badge variant="outline">{t(`caps.windows.${p.window}`)}</Badge></td>
|
||||
<td className="px-3 py-2 text-right">{p.max_tokens ? `${formatTokens(tokenUsed)} / ${formatTokens(p.max_tokens)} (${tokenPct}%)` : "—"}</td>
|
||||
<td className="px-3 py-2 text-right">{p.max_cost_micros ? `${formatCost(costUsed / 1_000_000)} / ${formatCost(p.max_cost_micros / 1_000_000)} (${costPct}%)` : "—"}</td>
|
||||
<td className="px-3 py-2 text-right"><Badge variant={p.enabled ? "default" : "secondary"}>{p.enabled ? t("caps.enabled") : t("caps.disabled")}</Badge></td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onDelete} aria-label={t("caps.delete")}><Trash2 className="h-4 w-4" /></Button>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onDelete} disabled={isAgentBudget} aria-label={t("caps.delete")} title={isAgentBudget ? t("caps.agentBudgetManaged") : undefined}><Trash2 className="h-4 w-4" /></Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user