From a7bf5fc3e2c4fd198e0cb512a102ffc222d80014 Mon Sep 17 00:00:00 2001 From: RAHUL KHANDELWAL <60139552+rahullli@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:17:00 +0530 Subject: [PATCH] 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 --- .../provider/litellm/resource_team_member.go | 20 ++-- .../litellm/resource_team_member_add.go | 27 +++--- .../litellm/resource_team_member_add_test.go | 16 +++- .../litellm/resource_team_member_test.go | 95 +++++++++++++++++++ 4 files changed, 131 insertions(+), 27 deletions(-) diff --git a/terraform/provider/litellm/resource_team_member.go b/terraform/provider/litellm/resource_team_member.go index 84db07239fd..be8c77dd7f0 100644 --- a/terraform/provider/litellm/resource_team_member.go +++ b/terraform/provider/litellm/resource_team_member.go @@ -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) diff --git a/terraform/provider/litellm/resource_team_member_add.go b/terraform/provider/litellm/resource_team_member_add.go index ca3541408ba..9ff34905315 100644 --- a/terraform/provider/litellm/resource_team_member_add.go +++ b/terraform/provider/litellm/resource_team_member_add.go @@ -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) diff --git a/terraform/provider/litellm/resource_team_member_add_test.go b/terraform/provider/litellm/resource_team_member_add_test.go index a2ddb0016bc..60b0890a9b6 100644 --- a/terraform/provider/litellm/resource_team_member_add_test.go +++ b/terraform/provider/litellm/resource_team_member_add_test.go @@ -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) diff --git a/terraform/provider/litellm/resource_team_member_test.go b/terraform/provider/litellm/resource_team_member_test.go index 156c4b3abfa..99ac2077b62 100644 --- a/terraform/provider/litellm/resource_team_member_test.go +++ b/terraform/provider/litellm/resource_team_member_test.go @@ -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) + } +}