fix(terraform): send litellm_key model_max_budget as per-model BudgetConfig objects (#40450)

* fix(terraform): send litellm_key model_max_budget as per-model BudgetConfig objects

The key resource typed model_max_budget as map(number) and forwarded the
bare numbers to /key/generate, which the proxy rejects with a 500
('int' object is not iterable) because each model entry must be a
BudgetConfig object. The attribute is now a JSON string of per-model
budget objects, matching litellm_user, litellm_budget and litellm_tag,
with a schema version 1 state upgrader that drops the old map value so
existing state keeps loading

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(terraform): reject litellm_key model_max_budget JSON that is not per-model budget objects

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(terraform): reject unknown or empty per-model budget fields in litellm_key model_max_budget

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-09 19:46:26 -07:00 • committed by GitHub
parent 8ec2f00955
commit cb9c3a9137
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 195 additions and 17 deletions

View file

@ -45,6 +45,7 @@ longer signal it.
### Changed
- **key** (breaking): `model_max_budget` on `litellm_key` is now a JSON string of per-model budget objects (`jsonencode({"gpt-4o-mini" = {budget_limit = 50, time_period = "30d"}})`), matching `litellm_user`, `litellm_budget` and `litellm_tag`. The old `map(number)` form sent bare numbers to `/key/generate`, which the proxy rejects with a 500 (`'int' object is not iterable`), so every key with a non-empty `model_max_budget` failed to apply. Existing state upgrades automatically (schema version 1) and the attribute is refilled from the proxy on the next read; configurations still using the map form must be rewritten
- **Versioning**: the provider is now published at the LiteLLM version, from the same commit as the proxy, on every LiteLLM release (dev, rc, stable). The `0.x` line ends at `0.4.0`; a `~> 0.4` constraint will not receive further releases, so re-pin to the LiteLLM version your proxy runs (for example `~> 1.99.0`). Existing `0.x` versions remain in the registry and keep verifying
## [0.4.0] - 2026-08-06

View file

@ -103,9 +103,12 @@ resource "litellm_key" "example_key" {
permissions = {
can_create_keys = "true"
}
model_max_budget = {
"gpt-4" = 50.0
}
model_max_budget = jsonencode({
"gpt-4" = {
budget_limit = 50.0
time_period = "30d"
}
})
model_rpm_limit = {
"claude-3.5-sonnet" = 30
}

View file

@ -30,9 +30,12 @@ resource "litellm_key" "example" {
permissions = {
"can_create_keys" = "true"
}
model_max_budget = {
"gpt-4" = 50.0
}
model_max_budget = jsonencode({
"gpt-4" = {
budget_limit = 50.0
time_period = "30d"
}
})
model_rpm_limit = {
"gpt-3.5-turbo" = 30
}
@ -81,7 +84,7 @@ The following arguments are supported:
* `permissions` - (Optional) Permissions associated with this key. This defines what actions are allowed with this key.
* `model_max_budget` - (Optional) Maximum budget per model. This allows setting different budget limits for each model.
* `model_max_budget` - (Optional) JSON string of per-model budget config, e.g. `jsonencode({"gpt-4" = {budget_limit = 50.0, time_period = "30d"}})`. Each model maps to an object with `budget_limit` (or `max_budget`), `time_period` (or `budget_duration`), `tpm_limit` and `rpm_limit`.
* `model_rpm_limit` - (Optional) Requests per minute limit per model. This allows setting different RPM limits for each model.

View file

@ -2,6 +2,7 @@ package litellm
import (
"context"
"encoding/json"
"fmt"
"github.com/hashicorp/go-cty/cty"
@ -10,7 +11,7 @@ import (
)
func resourceKey() *schema.Resource {
return &schema.Resource{
r := &schema.Resource{
CreateContext: resourceKeyCreate,
ReadContext: resourceKeyRead,
UpdateContext: resourceKeyUpdate,
@ -18,6 +19,7 @@ func resourceKey() *schema.Resource {
Importer: &schema.ResourceImporter{
StateContext: schema.ImportStatePassthroughContext,
},
SchemaVersion: 1,
Schema: map[string]*schema.Schema{
"key": {
Type: schema.TypeString,
@ -105,9 +107,11 @@ func resourceKey() *schema.Resource {
Elem: &schema.Schema{Type: schema.TypeString},
},
"model_max_budget": {
Type: schema.TypeMap,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeFloat, Computed: true},
Type: schema.TypeString,
Optional: true,
ValidateFunc: validateKeyModelMaxBudget,
DiffSuppressFunc: budgetSuppressEquivalentJSON,
Description: "JSON string of per-model budget config (e.g. '{\"gpt-4o-mini\": {\"budget_limit\": 50, \"time_period\": \"30d\"}}')",
},
"model_rpm_limit": {
Type: schema.TypeMap,
@ -182,6 +186,79 @@ func resourceKey() *schema.Resource {
},
},
}
r.StateUpgraders = []schema.StateUpgrader{{
Version: 0,
Type: resourceKeyV0Type(r.Schema),
Upgrade: resourceKeyStateUpgradeV0,
}}
return r
}
// Schema version 0 typed model_max_budget as map(number), which the proxy
// rejects; version 1 stores the per-model BudgetConfig objects as a JSON string.
func resourceKeyV0Type(current map[string]*schema.Schema) cty.Type {
v0 := make(map[string]*schema.Schema, len(current))
for k, v := range current {
v0[k] = v
}
v0["model_max_budget"] = &schema.Schema{
Type: schema.TypeMap,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeFloat},
}
return (&schema.Resource{Schema: v0}).CoreConfigSchema().ImpliedType()
}
func resourceKeyStateUpgradeV0(_ context.Context, rawState map[string]interface{}, _ interface{}) (map[string]interface{}, error) {
delete(rawState, "model_max_budget")
return rawState, nil
}
var keyModelBudgetFields = map[string]bool{
"budget_limit": true,
"max_budget": true,
"time_period": true,
"budget_duration": true,
"tpm_limit": true,
"rpm_limit": true,
}
func validateKeyModelMaxBudget(v interface{}, k string) ([]string, []error) {
var parsed map[string]json.RawMessage
if err := json.Unmarshal([]byte(v.(string)), &parsed); err != nil || parsed == nil {
return nil, []error{fmt.Errorf("%q must be a JSON object keyed by model name, got %s", k, v)}
}
for model, cfg := range parsed {
var budget map[string]json.RawMessage
if err := json.Unmarshal(cfg, &budget); err != nil || len(budget) == 0 {
return nil, []error{fmt.Errorf("%q[%q] must be a budget object such as {\"budget_limit\": 50, \"time_period\": \"30d\"}, got %s", k, model, cfg)}
}
for field := range budget {
if !keyModelBudgetFields[field] {
return nil, []error{fmt.Errorf("%q[%q] has unknown budget field %q; supported fields are budget_limit, max_budget, time_period, budget_duration, tpm_limit, rpm_limit", k, model, field)}
}
}
}
return nil, nil
}
func parseKeyModelMaxBudget(raw string) map[string]interface{} {
var parsed map[string]interface{}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil || parsed == nil {
return map[string]interface{}{}
}
return parsed
}
func keyModelMaxBudgetJSON(modelMaxBudget map[string]interface{}) string {
if len(modelMaxBudget) == 0 {
return ""
}
encoded, err := json.Marshal(modelMaxBudget)
if err != nil {
return ""
}
return string(encoded)
}
func resourceKeyCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
@ -342,7 +419,7 @@ func mapResourceDataToKey(d *schema.ResourceData, key *Key) {
key.Aliases = d.Get("aliases").(map[string]interface{})
key.Config = d.Get("config").(map[string]interface{})
key.Permissions = d.Get("permissions").(map[string]interface{})
key.ModelMaxBudget = d.Get("model_max_budget").(map[string]interface{})
key.ModelMaxBudget = parseKeyModelMaxBudget(d.Get("model_max_budget").(string))
key.ModelRPMLimit = d.Get("model_rpm_limit").(map[string]interface{})
key.ModelTPMLimit = d.Get("model_tpm_limit").(map[string]interface{})
key.Guardrails = expandStringList(d.Get("guardrails").([]interface{}))
@ -415,9 +492,7 @@ func mapKeyToResourceData(d *schema.ResourceData, key *Key) {
if key.Permissions != nil {
d.Set("permissions", key.Permissions)
}
if key.ModelMaxBudget != nil {
d.Set("model_max_budget", key.ModelMaxBudget)
}
d.Set("model_max_budget", keyModelMaxBudgetJSON(key.ModelMaxBudget))
if key.ModelRPMLimit != nil {
d.Set("model_rpm_limit", key.ModelRPMLimit)
}

View file

@ -195,6 +195,102 @@ func TestCreateKeySendsConfigSuppliedKey(t *testing.T) {
}
}
// The proxy validates each model_max_budget entry as a BudgetConfig object and
// 500s on a bare number, so the JSON string must reach /key/generate as nested
// objects and the proxy's response must map back to equivalent JSON in state.
func TestCreateKeySendsModelMaxBudgetAsBudgetObjects(t *testing.T) {
var captured map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/key/generate" {
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &captured)
w.Write([]byte(`{"key": "sk-test", "token_id": "hash-1"}`))
return
}
w.Write([]byte(`{"key": "hash-1", "info": {"model_max_budget": {"gpt-4o-mini": {"budget_limit": 50, "time_period": "30d", "rpm_limit": 60}}}}`))
}))
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := newKeyResourceData(t, map[string]interface{}{
"model_max_budget": `{"gpt-4o-mini": {"budget_limit": 50, "time_period": "30d"}}`,
})
if diags := resourceKeyCreate(context.Background(), d, client); diags.HasError() {
t.Fatalf("create returned error: %v", diags)
}
budgets, ok := captured["model_max_budget"].(map[string]interface{})
if !ok {
t.Fatalf("create payload model_max_budget = %v, want object", captured["model_max_budget"])
}
cfg, ok := budgets["gpt-4o-mini"].(map[string]interface{})
if !ok {
t.Fatalf("model_max_budget[gpt-4o-mini] = %v, want BudgetConfig object", budgets["gpt-4o-mini"])
}
if cfg["budget_limit"] != float64(50) || cfg["time_period"] != "30d" {
t.Errorf("BudgetConfig = %v, want budget_limit 50 and time_period 30d", cfg)
}
var state map[string]interface{}
if err := json.Unmarshal([]byte(d.Get("model_max_budget").(string)), &state); err != nil {
t.Fatalf("state model_max_budget %q is not JSON: %v", d.Get("model_max_budget"), err)
}
if got, _ := state["gpt-4o-mini"].(map[string]interface{}); got["budget_limit"] != float64(50) || got["rpm_limit"] != float64(60) {
t.Errorf("state model_max_budget = %v, want the BudgetConfig read back from /key/info", state)
}
}
// Schema version 0 stored model_max_budget as map(number); that state cannot
// decode into the version 1 string attribute, so the upgrader must drop it.
func TestKeyStateUpgradeV0DropsMapModelMaxBudget(t *testing.T) {
upgraded, err := resourceKey().StateUpgraders[0].Upgrade(context.Background(), map[string]interface{}{
"id": "hash-1",
"key_alias": "legacy",
"model_max_budget": map[string]interface{}{"gpt-4o-mini": 50.0},
}, nil)
if err != nil {
t.Fatalf("upgrade returned error: %v", err)
}
if _, present := upgraded["model_max_budget"]; present {
t.Errorf("upgraded state still carries map model_max_budget: %v", upgraded["model_max_budget"])
}
if upgraded["key_alias"] != "legacy" {
t.Errorf("upgrade dropped unrelated attribute: %v", upgraded)
}
}
func TestKeyModelMaxBudgetValidationRequiresBudgetObjects(t *testing.T) {
validate := resourceKey().Schema["model_max_budget"].ValidateFunc
for _, valid := range []string{
`{}`,
`{"gpt-4o-mini": {"budget_limit": 50, "time_period": "30d"}}`,
`{"gpt-4o-mini": {"max_budget": 50, "rpm_limit": 60}, "gpt-4o": {"budget_duration": "1d", "tpm_limit": 1000}}`,
} {
if _, errs := validate(valid, "model_max_budget"); len(errs) != 0 {
t.Errorf("validate(%s) = %v, want accepted", valid, errs)
}
}
for _, invalid := range []string{
`null`,
`[]`,
`"gpt-4o-mini"`,
`50`,
`{"gpt-4o-mini": 50}`,
`{"gpt-4o-mini": null}`,
`{"gpt-4o-mini": [50]}`,
`{"gpt-4o-mini": {}}`,
`{"gpt-4o-mini": {"budget_limt": 50}}`,
`{"gpt-4o-mini": {"budget_limit": 50, "max_tokens": 100}}`,
`not json`,
} {
if _, errs := validate(invalid, "model_max_budget"); len(errs) == 0 {
t.Errorf("validate(%s) accepted a value that would send no per-model budget", invalid)
}
}
}
// The proxy 400s on budget_duration: "", so an unset duration must be
// omitted from the update payload entirely.
func TestUpdateKeyOmitsEmptyBudgetDuration(t *testing.T) {

View file

@ -65,7 +65,7 @@ func buildKeyData(d *schema.ResourceData) map[string]interface{} {
keyData["permissions"] = v.(map[string]interface{})
}
if v, ok := d.GetOkExists("model_max_budget"); ok {
keyData["model_max_budget"] = v.(map[string]interface{})
keyData["model_max_budget"] = parseKeyModelMaxBudget(v.(string))
}
if v, ok := d.GetOkExists("model_rpm_limit"); ok {
keyData["model_rpm_limit"] = v.(map[string]interface{})
@ -107,7 +107,7 @@ func setKeyResourceData(d *schema.ResourceData, key *Key) error {
"aliases": key.Aliases,
"config": key.Config,
"permissions": key.Permissions,
"model_max_budget": key.ModelMaxBudget,
"model_max_budget": keyModelMaxBudgetJSON(key.ModelMaxBudget),
"model_rpm_limit": key.ModelRPMLimit,
"model_tpm_limit": key.ModelTPMLimit,
"guardrails": key.Guardrails,