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
23 changes: 23 additions & 0 deletions docs/features/governance/budget-and-limits.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,29 @@ Quarter boundaries repeat every three months, so `quarter_start_month` only chan

Changing `quarter_start_month` on a live calendar-aligned budget takes effect on the next reset tick rather than instantly. The new definition is stored immediately, which moves where the current window starts; if that boundary has moved forward, the budget reads as due and resets shortly after, zeroing usage. That is the honest outcome - under the new calendar the current quarter genuinely began on a later date.

### Resetting budget usage

Changing a budget's amount or reset frequency does not clear the spend already recorded against it. To
clear it, send `reset_budget_usage: true` on the update request. The UI asks before saving whenever a
budget's configuration changed, offering **Preserve Usage** or **Reset Usage**.

```json
{
"budgets": [{ "max_limit": 5000, "reset_duration": "1M" }],
"reset_budget_usage": true
}
```

Supported on virtual keys, teams, customers, model limits, and provider governance.

<Note>
The reset clears **usage only**. The reset window keeps its existing start and end, because `last_reset`
only ever advances and never moves as a side effect of a configuration write. That rule is what keeps
every node in a cluster agreeing on which window is currently open. The normal scheduled reset is
unaffected and still advances `last_reset` when the window closes, so a manually cleared budget resets
again at its usual boundary rather than starting a fresh window from the moment you cleared it.
</Note>

### Budget overrides

A budget can carry a temporary **override** that adds spending capacity on top of its configured limit without touching the base limit, current usage, or reset schedule. While an override is active, enforcement uses:
Expand Down
18 changes: 17 additions & 1 deletion docs/openapi/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -82486,7 +82486,7 @@
},
"reset_budget_usage": {
"type": "boolean",
"description": "When true, resets usage for virtual-key and provider-config budget records reconciled by this update."
"description": "When true, zeroes current usage on the budgets reconciled by this update. The reset window is left alone: last_reset only ever advances, and never as a side effect of a configuration write, so the current window keeps its existing start and end.\n"
},
"expires_at": {
"type": "string",
Expand Down Expand Up @@ -82620,6 +82620,10 @@
"items": {
"$ref": "#/components/schemas/CreateBudgetRequest"
}
},
"reset_budget_usage": {
"type": "boolean",
"description": "When true, zeroes current usage on the budgets reconciled by this update. The reset window is left alone: last_reset only ever advances, and never as a side effect of a configuration write, so the current window keeps its existing start and end.\n"
}
}
},
Expand Down Expand Up @@ -82723,6 +82727,10 @@
},
"budget": {
"$ref": "#/components/schemas/UpdateBudgetRequest"
},
"reset_budget_usage": {
"type": "boolean",
"description": "When true, zeroes current usage on the budgets reconciled by this update. The reset window is left alone: last_reset only ever advances, and never as a side effect of a configuration write, so the current window keeps its existing start and end.\n"
}
}
},
Expand Down Expand Up @@ -84010,6 +84018,10 @@
"rate_limit": {
"$ref": "#/components/schemas/UpdateRateLimitRequest",
"description": "Rate limit configuration"
},
"reset_budget_usage": {
"type": "boolean",
"description": "When true, zeroes current usage on the budgets reconciled by this update. The reset window is left alone: last_reset only ever advances, and never as a side effect of a configuration write, so the current window keeps its existing start and end.\n"
}
}
},
Expand Down Expand Up @@ -84101,6 +84113,10 @@
"type": "boolean",
"nullable": true,
"description": "When true, all budgets reset at clean calendar boundaries. Omit to leave unchanged."
},
"reset_budget_usage": {
"type": "boolean",
"description": "When true, zeroes current usage on the budgets reconciled by this update. The reset window is left alone: last_reset only ever advances, and never as a side effect of a configuration write, so the current window keeps its existing start and end.\n"
}
}
},
Expand Down
29 changes: 28 additions & 1 deletion docs/openapi/schemas/management/governance.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,10 @@ UpdateVirtualKeyRequest:
type: boolean
reset_budget_usage:
type: boolean
description: When true, resets usage for virtual-key and provider-config budget records reconciled by this update.
description: >
When true, zeroes current usage on the budgets reconciled by this update. The reset window is
left alone: last_reset only ever advances, and never as a side effect of a configuration write,
so the current window keeps its existing start and end.
expires_at:
type: string
description: RFC3339 timestamp to set a new expiry (must be in the future), empty string to clear an existing expiry, omitted to leave it unchanged.
Expand Down Expand Up @@ -787,6 +790,12 @@ UpdateTeamRequest:
description: Replacement set of budgets for this team; reset durations must be unique
items:
$ref: '#/CreateBudgetRequest'
reset_budget_usage:
type: boolean
description: >
When true, zeroes current usage on the budgets reconciled by this update. The reset window is
left alone: last_reset only ever advances, and never as a side effect of a configuration write,
so the current window keeps its existing start and end.

ListTeamsResponse:
type: object
Expand Down Expand Up @@ -864,6 +873,12 @@ UpdateCustomerRequest:
type: string
budget:
$ref: '#/UpdateBudgetRequest'
reset_budget_usage:
type: boolean
description: >
When true, zeroes current usage on the budgets reconciled by this update. The reset window is
left alone: last_reset only ever advances, and never as a side effect of a configuration write,
so the current window keeps its existing start and end.

ListCustomersResponse:
type: object
Expand Down Expand Up @@ -1518,6 +1533,12 @@ UpdateModelConfigRequest:
rate_limit:
$ref: '#/UpdateRateLimitRequest'
description: Rate limit configuration
reset_budget_usage:
type: boolean
description: >
When true, zeroes current usage on the budgets reconciled by this update. The reset window is
left alone: last_reset only ever advances, and never as a side effect of a configuration write,
so the current window keeps its existing start and end.

# Provider Governance

Expand Down Expand Up @@ -1593,6 +1614,12 @@ UpdateProviderGovernanceRequest:
type: boolean
nullable: true
description: When true, all budgets reset at clean calendar boundaries. Omit to leave unchanged.
reset_budget_usage:
type: boolean
description: >
When true, zeroes current usage on the budgets reconciled by this update. The reset window is
left alone: last_reset only ever advances, and never as a side effect of a configuration write,
so the current window keeps its existing start and end.

# Pricing Overrides

Expand Down
170 changes: 170 additions & 0 deletions tests/governance/customerbudget_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -412,3 +412,173 @@ func customerBudgetLastReset(t *testing.T, customerID string) time.Time {
}
return parsed.UTC()
}

// TestCustomerResetBudgetUsageClearsSpend covers the customer owner's reset path,
// which goes through reconcileCustomerBudgets rather than the team inline loop or
// the model-config reconciler.
func TestCustomerResetBudgetUsageClearsSpend(t *testing.T) {
testData := NewGlobalTestData()
defer testData.Cleanup(t)

createCustomerResp := MakeRequest(t, APIRequest{
Method: "POST",
Path: "/api/governance/customers",
Body: CreateCustomerRequest{
Name: "test-customer-reset-usage-" + generateRandomID(),
Budgets: []BudgetRequest{{MaxLimit: 50, ResetDuration: "1M"}},
},
})
if createCustomerResp.StatusCode != 200 {
t.Fatalf("Failed to create customer: status %d, body %v", createCustomerResp.StatusCode, createCustomerResp.Body)
}
customerID := ExtractIDFromResponse(t, createCustomerResp)
testData.AddCustomer(customerID)

createVKResp := MakeRequest(t, APIRequest{
Method: "POST",
Path: "/api/governance/virtual-keys",
Body: CreateVirtualKeyRequest{
Name: "test-vk-customer-reset-" + generateRandomID(),
CustomerID: &customerID,
ProviderConfigs: defaultProviderConfigs(),
},
})
if createVKResp.StatusCode != 200 {
t.Fatalf("Failed to create VK: status %d, body %v", createVKResp.StatusCode, createVKResp.Body)
}
vkID := ExtractIDFromResponse(t, createVKResp)
testData.AddVirtualKey(vkID)
vkValue := createVKResp.Body["virtual_key"].(map[string]interface{})["value"].(string)

spendVia(t, vkValue)

deadline := time.Now().Add(30 * time.Second)
var spent float64
for {
spent = customerBudgetUsage(t, customerID)
if spent > 0 {
break
}
if time.Now().After(deadline) {
t.Fatalf("customer %s recorded no budget usage after a successful completion", customerID)
}
time.Sleep(time.Second)
}
t.Logf("customer recorded $%.8f of usage before the reset", spent)

resetUsage := true
updateResp := MakeRequest(t, APIRequest{
Method: "PUT",
Path: "/api/governance/customers/" + customerID,
Body: UpdateCustomerRequest{
Budgets: []BudgetRequest{{MaxLimit: 60, ResetDuration: "1M"}},
ResetBudgetUsage: &resetUsage,
},
})
if updateResp.StatusCode != 200 {
t.Fatalf("Failed to reset customer budget usage: status %d, body %v", updateResp.StatusCode, updateResp.Body)
}

if usage := customerBudgetUsage(t, customerID); usage != 0 {
t.Errorf("reset_budget_usage did not clear customer spend: usage is still %v", usage)
}
}

// customerBudgetUsage reads the first budget's current usage off a customer.
func customerBudgetUsage(t *testing.T, customerID string) float64 {
t.Helper()
resp := MakeRequest(t, APIRequest{Method: "GET", Path: "/api/governance/customers/" + customerID})
if resp.StatusCode != 200 {
t.Fatalf("Failed to read customer %s: status %d", customerID, resp.StatusCode)
}
customer, ok := resp.Body["customer"].(map[string]interface{})
if !ok {
customer = resp.Body
}
budgets, ok := customer["budgets"].([]interface{})
if !ok || len(budgets) == 0 {
t.Fatalf("customer %s has no budgets: %v", customerID, customer)
}
budget, ok := budgets[0].(map[string]interface{})
if !ok {
t.Fatalf("customer %s first budget is not an object: %v", customerID, budgets[0])
}
// Checked rather than defaulted to 0: every reset assertion is "usage is now
// zero", so a helper that silently returns 0 for a missing field would make
// those assertions pass without the reset having done anything.
usage, ok := budget["current_usage"].(float64)
if !ok {
t.Fatalf("customer %s budget has no current_usage: %v", customerID, budget)
}
return usage
}

// TestCustomerCalendarAlignmentPreservesUsage pins the interaction between the two
// features: enabling calendar alignment must not clear spend as a side effect.
//
// The handler used to attempt exactly that, and it never worked because the write
// went through UpdateBudget, which carries usage forward. Now that a working reset
// path exists, an implementation could plausibly wire alignment into it and start
// silently wiping usage on a toggle. Clearing spend is only ever the operator's
// explicit choice via reset_budget_usage.
func TestCustomerCalendarAlignmentPreservesUsage(t *testing.T) {
testData := NewGlobalTestData()
defer testData.Cleanup(t)

createCustomerResp := MakeRequest(t, APIRequest{
Method: "POST",
Path: "/api/governance/customers",
Body: CreateCustomerRequest{
Name: "test-customer-align-usage-" + generateRandomID(),
Budgets: []BudgetRequest{{MaxLimit: 50, ResetDuration: "1M"}},
},
})
if createCustomerResp.StatusCode != 200 {
t.Fatalf("Failed to create customer: status %d, body %v", createCustomerResp.StatusCode, createCustomerResp.Body)
}
customerID := ExtractIDFromResponse(t, createCustomerResp)
testData.AddCustomer(customerID)

createVKResp := MakeRequest(t, APIRequest{
Method: "POST",
Path: "/api/governance/virtual-keys",
Body: CreateVirtualKeyRequest{
Name: "test-vk-customer-align-" + generateRandomID(),
CustomerID: &customerID,
ProviderConfigs: defaultProviderConfigs(),
},
})
if createVKResp.StatusCode != 200 {
t.Fatalf("Failed to create VK: status %d, body %v", createVKResp.StatusCode, createVKResp.Body)
}
vkID := ExtractIDFromResponse(t, createVKResp)
testData.AddVirtualKey(vkID)
spendVia(t, createVKResp.Body["virtual_key"].(map[string]interface{})["value"].(string))

deadline := time.Now().Add(30 * time.Second)
var spent float64
for {
spent = customerBudgetUsage(t, customerID)
if spent > 0 {
break
}
if time.Now().After(deadline) {
t.Fatalf("customer %s recorded no usage after a successful completion", customerID)
}
time.Sleep(time.Second)
}

aligned := true
updateResp := MakeRequest(t, APIRequest{
Method: "PUT",
Path: "/api/governance/customers/" + customerID,
Body: UpdateCustomerRequest{CalendarAligned: &aligned},
})
if updateResp.StatusCode != 200 {
t.Fatalf("Failed to enable calendar alignment: status %d, body %v", updateResp.StatusCode, updateResp.Body)
}

if usage := customerBudgetUsage(t, customerID); usage != spent {
t.Errorf("enabling calendar alignment changed usage from %v to %v; only reset_budget_usage may clear spend", spent, usage)
}
}
Loading
Loading