fix(terraform): omit max_budget_in_team when it is unset

`max_budget_in_team` is a TypeFloat read with d.Get, so an unset attribute
became 0.0 and was always sent. The proxy treats 0.0 as a configured
budget, so members created without the attribute got a $0 cap and every
request from them failed with budget_exceeded.

Send the field only when it is set, matching the existing GetOk handling
for tpm_limit, rpm_limit, budget_duration and allowed_models, and clear
it with an explicit null when it is removed from config. Applies to both
litellm_team_member and litellm_team_member_add.

Fixes #49669
This commit is contained in:
RAHUL KHANDELWAL 2026-11-03 06:17:00 +05:30
parent 28b4300be2
commit a7bf5fc3e2
4 changed files with 131 additions and 27 deletions

View file

@ -58,8 +58,10 @@ func resourceLiteLLMTeamMemberCreate(d *schema.ResourceData, m interface{}) erro
"user_email": d.Get("user_email").(string),
},
},
"team_id": d.Get("team_id").(string),
"max_budget_in_team": d.Get("max_budget_in_team").(float64),
"team_id": d.Get("team_id").(string),
}
if v, ok := d.GetOk("max_budget_in_team"); ok {
memberData["max_budget_in_team"] = v.(float64)
}
log.Printf("[DEBUG] Create team member request payload: %+v", memberData)
@ -94,11 +96,15 @@ func resourceLiteLLMTeamMemberUpdate(d *schema.ResourceData, m interface{}) erro
client := m.(*Client)
updateData := map[string]interface{}{
"user_id": d.Get("user_id").(string),
"user_email": d.Get("user_email").(string),
"team_id": d.Get("team_id").(string),
"role": d.Get("role").(string),
"max_budget_in_team": d.Get("max_budget_in_team").(float64),
"user_id": d.Get("user_id").(string),
"user_email": d.Get("user_email").(string),
"team_id": d.Get("team_id").(string),
"role": d.Get("role").(string),
}
if v, ok := d.GetOk("max_budget_in_team"); ok {
updateData["max_budget_in_team"] = v.(float64)
} else if d.HasChange("max_budget_in_team") {
updateData["max_budget_in_team"] = nil
}
log.Printf("[DEBUG] Update team member request payload: %+v", updateData)

View file

@ -79,6 +79,9 @@ func expandAllowedModels(raw []interface{}) []string {
}
func applyAddOnlySettings(d *schema.ResourceData, payload map[string]interface{}) {
if v, ok := d.GetOk("max_budget_in_team"); ok {
payload["max_budget_in_team"] = v.(float64)
}
if v, ok := d.GetOk("budget_duration"); ok {
payload["budget_duration"] = v.(string)
}
@ -98,7 +101,7 @@ func applyLimits(d *schema.ResourceData, payload map[string]interface{}) {
func applyUpdateSettings(d *schema.ResourceData, payload map[string]interface{}) {
applyAddOnlySettings(d, payload)
applyLimits(d, payload)
for _, key := range []string{"tpm_limit", "rpm_limit", "budget_duration"} {
for _, key := range []string{"max_budget_in_team", "tpm_limit", "rpm_limit", "budget_duration"} {
if _, ok := d.GetOk(key); !ok && d.HasChange(key) {
payload[key] = nil
}
@ -153,7 +156,6 @@ func resourceLiteLLMTeamMemberAddCreate(d *schema.ResourceData, m interface{}) e
teamID := d.Get("team_id").(string)
members := d.Get("member").(*schema.Set)
maxBudget := d.Get("max_budget_in_team").(float64)
// Convert members to the expected format
membersList := make([]map[string]interface{}, 0, members.Len())
@ -172,9 +174,8 @@ func resourceLiteLLMTeamMemberAddCreate(d *schema.ResourceData, m interface{}) e
}
memberData := map[string]interface{}{
"member": membersList,
"team_id": teamID,
"max_budget_in_team": maxBudget,
"member": membersList,
"team_id": teamID,
}
applyAddOnlySettings(d, memberData)
@ -208,7 +209,6 @@ func resourceLiteLLMTeamMemberAddRead(d *schema.ResourceData, m interface{}) err
func resourceLiteLLMTeamMemberAddUpdate(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
teamID := d.Get("team_id").(string)
maxBudget := d.Get("max_budget_in_team").(float64)
o, n := d.GetChange("member")
oldMembers := o.(*schema.Set)
@ -249,9 +249,8 @@ func resourceLiteLLMTeamMemberAddUpdate(d *schema.ResourceData, m interface{}) e
for key, newMember := range newMemberMap {
if _, exists := oldMemberMap[key]; exists {
updateData := map[string]interface{}{
"team_id": teamID,
"role": newMember["role"].(string),
"max_budget_in_team": maxBudget,
"team_id": teamID,
"role": newMember["role"].(string),
}
applyUpdateSettings(d, updateData)
memberIdentity(newMember, updateData)
@ -313,9 +312,8 @@ func resourceLiteLLMTeamMemberAddUpdate(d *schema.ResourceData, m interface{}) e
// Check if member attributes have changed
if memberAttributesChanged(oldMember, newMember) {
updateData := map[string]interface{}{
"team_id": teamID,
"role": newMember["role"].(string),
"max_budget_in_team": maxBudget,
"team_id": teamID,
"role": newMember["role"].(string),
}
applyUpdateSettings(d, updateData)
memberIdentity(newMember, updateData)
@ -354,9 +352,8 @@ func resourceLiteLLMTeamMemberAddUpdate(d *schema.ResourceData, m interface{}) e
if len(membersToAdd) > 0 {
memberData := map[string]interface{}{
"member": membersToAdd,
"team_id": teamID,
"max_budget_in_team": maxBudget,
"member": membersToAdd,
"team_id": teamID,
}
applyAddOnlySettings(d, memberData)

View file

@ -56,6 +56,9 @@ func TestTeamMemberAddCreateSendsMemberSettings(t *testing.T) {
if addPayload["budget_duration"] != "30d" {
t.Fatalf("member_add payload sent budget_duration %v, want 30d", addPayload["budget_duration"])
}
if addPayload["max_budget_in_team"] != float64(25) {
t.Fatalf("member_add payload sent max_budget_in_team %v, want 25", addPayload["max_budget_in_team"])
}
wantModels := []interface{}{"claude-opus-4-6-v1"}
if !reflect.DeepEqual(addPayload["allowed_models"], wantModels) {
t.Fatalf("member_add payload sent allowed_models %v, want %v", addPayload["allowed_models"], wantModels)
@ -111,7 +114,7 @@ func TestTeamMemberAddCreateOmitsUnsetSettings(t *testing.T) {
t.Fatalf("create failed: %v", err)
}
for _, field := range []string{"tpm_limit", "rpm_limit", "budget_duration", "allowed_models"} {
for _, field := range []string{"max_budget_in_team", "tpm_limit", "rpm_limit", "budget_duration", "allowed_models"} {
if _, ok := addPayload[field]; ok {
t.Fatalf("member_add payload must not carry unset %s, got %v", field, addPayload[field])
}
@ -216,11 +219,14 @@ func runTeamMemberUpdate(t *testing.T, d *schema.ResourceData) []map[string]inte
func TestTeamMemberAddUpdateSendsChangedSettings(t *testing.T) {
d := newTeamMemberUpdateResourceData(t,
map[string]string{"tpm_limit": "1000", "rpm_limit": "10", "budget_duration": "30d"},
map[string]string{"tpm_limit": "500", "rpm_limit": "5", "budget_duration": "7d", "allowed_models.#": "1", "allowed_models.0": "gpt-5.2"},
map[string]string{"max_budget_in_team": "25", "tpm_limit": "1000", "rpm_limit": "10", "budget_duration": "30d"},
map[string]string{"max_budget_in_team": "50", "tpm_limit": "500", "rpm_limit": "5", "budget_duration": "7d", "allowed_models.#": "1", "allowed_models.0": "gpt-5.2"},
)
update := runTeamMemberUpdate(t, d)[0]
if update["max_budget_in_team"] != float64(50) {
t.Fatalf("member_update payload max_budget_in_team = %v, want 50", update["max_budget_in_team"])
}
if update["tpm_limit"] != float64(500) || update["rpm_limit"] != float64(5) {
t.Fatalf("member_update payload limits = %v/%v, want 500/5", update["tpm_limit"], update["rpm_limit"])
}
@ -237,12 +243,12 @@ func TestTeamMemberAddUpdateSendsChangedSettings(t *testing.T) {
func TestTeamMemberAddUpdateClearsRemovedSettings(t *testing.T) {
d := newTeamMemberUpdateResourceData(t,
map[string]string{"tpm_limit": "1000", "rpm_limit": "10", "budget_duration": "30d", "allowed_models.#": "1", "allowed_models.0": "gpt-5.2"},
map[string]string{"max_budget_in_team": "25", "tpm_limit": "1000", "rpm_limit": "10", "budget_duration": "30d", "allowed_models.#": "1", "allowed_models.0": "gpt-5.2"},
map[string]string{"allowed_models.#": "0"},
)
update := runTeamMemberUpdate(t, d)[0]
for _, field := range []string{"tpm_limit", "rpm_limit", "budget_duration"} {
for _, field := range []string{"max_budget_in_team", "tpm_limit", "rpm_limit", "budget_duration"} {
v, present := update[field]
if !present {
t.Fatalf("member_update payload omitted removed %s, so the proxy would keep the old value", field)

View file

@ -8,6 +8,7 @@ import (
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
)
func TestTeamMemberUpdateSendsRole(t *testing.T) {
@ -42,3 +43,97 @@ func TestTeamMemberUpdateSendsRole(t *testing.T) {
t.Fatalf("update payload sent role %v, want user", role)
}
}
func teamMemberResourceData(t *testing.T, extra map[string]interface{}) *schema.ResourceData {
t.Helper()
raw := map[string]interface{}{
"team_id": "team-1",
"user_id": "user-1",
"user_email": "user@example.com",
"role": "user",
}
for k, v := range extra {
raw[k] = v
}
return schema.TestResourceDataRaw(t, resourceLiteLLMTeamMember().Schema, raw)
}
func newTeamMemberDiffResourceData(t *testing.T, old, new map[string]string) *schema.ResourceData {
t.Helper()
attrs := map[string]string{
"team_id": "team-1",
"user_id": "user-1",
"user_email": "user@example.com",
"role": "user",
}
for k, v := range old {
attrs[k] = v
}
diffAttrs := map[string]*terraform.ResourceAttrDiff{}
for k, v := range new {
diffAttrs[k] = &terraform.ResourceAttrDiff{Old: attrs[k], New: v}
}
for k := range old {
if _, ok := new[k]; !ok {
diffAttrs[k] = &terraform.ResourceAttrDiff{Old: attrs[k], New: "", NewRemoved: true}
}
}
state := &terraform.InstanceState{ID: "team-1:user-1", Attributes: attrs}
d, err := schema.InternalMap(resourceLiteLLMTeamMember().Schema).Data(state, &terraform.InstanceDiff{Attributes: diffAttrs})
if err != nil {
t.Fatalf("building ResourceData returned error: %v", err)
}
return d
}
func captureTeamMemberPayload(t *testing.T, d *schema.ResourceData, apply func(*schema.ResourceData, interface{}) error) map[string]interface{} {
t.Helper()
var captured map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &captured)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{}`))
}))
defer srv.Close()
if err := apply(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("request failed: %v", err)
}
return captured
}
func TestTeamMemberCreateOmitsUnsetMaxBudget(t *testing.T) {
payload := captureTeamMemberPayload(t, teamMemberResourceData(t, nil), resourceLiteLLMTeamMemberCreate)
if v, ok := payload["max_budget_in_team"]; ok {
t.Fatalf("member_add payload must not carry an unset max_budget_in_team, got %v", v)
}
}
func TestTeamMemberCreateSendsMaxBudget(t *testing.T) {
d := teamMemberResourceData(t, map[string]interface{}{"max_budget_in_team": 25.0})
payload := captureTeamMemberPayload(t, d, resourceLiteLLMTeamMemberCreate)
if payload["max_budget_in_team"] != float64(25) {
t.Fatalf("member_add payload sent max_budget_in_team %v, want 25", payload["max_budget_in_team"])
}
}
func TestTeamMemberUpdateOmitsUnsetMaxBudget(t *testing.T) {
payload := captureTeamMemberPayload(t, teamMemberResourceData(t, nil), resourceLiteLLMTeamMemberUpdate)
if v, ok := payload["max_budget_in_team"]; ok {
t.Fatalf("member_update payload must not carry an unset max_budget_in_team, got %v", v)
}
}
func TestTeamMemberUpdateClearsRemovedMaxBudget(t *testing.T) {
d := newTeamMemberDiffResourceData(t, map[string]string{"max_budget_in_team": "25"}, nil)
payload := captureTeamMemberPayload(t, d, resourceLiteLLMTeamMemberUpdate)
v, ok := payload["max_budget_in_team"]
if !ok {
t.Fatal("member_update payload omitted removed max_budget_in_team, so the proxy would keep the old budget")
}
if v != nil {
t.Fatalf("member_update payload max_budget_in_team = %v, want explicit null", v)
}
}