feat(usage): bridge agent monthly budget caps

This commit is contained in:
Goon committed 2026-05-23 08:51:04 +07:00
1 parent 3e29b4f019
commit a9a6463f71
18 files changed
+296 -17

No files matched your search

+2
View File
@@ -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.
---
+4 -2
View File
@@ -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.
---
+2
View File
@@ -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
+1
View File
@@ -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**
+7 -1
View File
@@ -60,6 +60,10 @@ func (m *AgentsMethods) handleUpdate(ctx context.Context, client *gateway.Client
if req.Params != nil {
json.Unmarshal(req.Params, &params)
}
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
+9
View File
@@ -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
}
+95
View File
@@ -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 &cents, 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
}
+28 -10
View File
@@ -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
}
+8 -1
View File
@@ -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"`
+1 -1
View File
@@ -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;
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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>
);
+1
View File
@@ -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;