From 79a30475d31d638fefae4adde4d8a174c38f08ce Mon Sep 17 00:00:00 2001 From: Marcus Greer Date: Tue, 20 Oct 2026 20:58:13 +0000 Subject: [PATCH] fix(terraform): read litellm_team member budget and passthrough routes back /team/info returns the member budget, its duration and the member rpm/tpm limits only inside team_member_budget_table, and allowed_passthrough_routes only inside metadata. Read looked for top-level fields that never exist, so a team that set any of them planned a diff on every run and out-of-band changes never showed as drift Read them from where the proxy returns them, and send an explicit null (or [] for the routes) when one is removed, since the proxy leaves a missing field unchanged. team_member_budget_duration is computed because the proxy copies the team's budget_duration into the member budget when it is not set --- terraform/provider/CHANGELOG.md | 1 + terraform/provider/docs/resources/team.md | 4 +- terraform/provider/litellm/resource_team.go | 42 +++-- .../provider/litellm/resource_team_test.go | 174 ++++++++++++++++++ terraform/provider/litellm/types.go | 6 +- 5 files changed, 211 insertions(+), 16 deletions(-) diff --git a/terraform/provider/CHANGELOG.md b/terraform/provider/CHANGELOG.md index 597e0c6d4d8..601e756022b 100644 --- a/terraform/provider/CHANGELOG.md +++ b/terraform/provider/CHANGELOG.md @@ -51,6 +51,7 @@ longer signal it. ### Fixed +- **team**: `litellm_team` refresh now reads `team_member_budget`, `team_member_budget_duration`, `team_member_rpm_limit` and `team_member_tpm_limit` from `team_member_budget_table` and `allowed_passthrough_routes` from `metadata`, which is where `/team/info` returns them, so a configuration that sets them no longer plans a diff on every run and out-of-band changes show as drift. Removing one of them now clears it on the proxy (explicit `null` for the member budget and limits, `[]` for the routes) instead of leaving the old value in place. `team_member_budget_duration` is computed, because the proxy copies the team's `budget_duration` into the member budget when it is not set - **model**: `additional_drop_params` in `litellm_model.additional_litellm_params` is now sent to the proxy as the deployment's `additional_drop_params` list, the same list a `model_list` entry takes. Before, the provider never sent the list and instead deleted every key it named from `litellm_params`, so a `store` or `vertex_location` listed there was lost and requests still carried the parameters (#38349). The value must be a JSON list of parameter names such as `jsonencode(["store"])`, which `terraform plan` now checks, and `null` entries are skipped as before. When the configuration sets the list, refresh reads the stored list back from the proxy, so a list changed outside Terraform shows up as drift. On a model an older provider created, the proxy has no stored list, so the first plan after upgrading shows the list and every listed key the configuration sets as changes, since that apply writes them. A write-only secret such as `model_api_key_wo` is never shown in a plan - **user**: `litellm_user` updates no longer send `blocked`, which `/user/update` writes into the user table where no such column exists, so every update failed with a 400 (`Could not find field at upsertOneLiteLLM_UserTable.create.blocked`). Create still sends `blocked`, including `false`, so a `default_internal_user_params.blocked` on the proxy cannot block the auto-created key. Changing `blocked` after creation has no effect, since the proxy has no user-level block; use `litellm_key_block` instead - **model**: `litellm_model` refresh now reads the `{"data": [...]}` envelope `/model/info` returns, so `model_info` fields changed outside Terraform show up as drift instead of silently keeping the previous state diff --git a/terraform/provider/docs/resources/team.md b/terraform/provider/docs/resources/team.md index 66511be8317..7f366c3f6c8 100644 --- a/terraform/provider/docs/resources/team.md +++ b/terraform/provider/docs/resources/team.md @@ -160,7 +160,7 @@ The following arguments are supported: * `team_member_budget` - (Optional) Budget (in USD) applied to each individual team member. -* `team_member_budget_duration` - (Optional) Reset cycle for the per-member budget (e.g. `30d`, `1mo`). +* `team_member_budget_duration` - (Optional) Reset cycle for the per-member budget (e.g. `30d`, `1mo`). When it is not set, the proxy uses the team's `budget_duration`, and that value is read back into state without planning a change. * `team_member_rpm_limit` - (Optional) Requests per minute limit applied to each individual team member. @@ -174,7 +174,7 @@ The following arguments are supported: * `model_tpm_limit` - (Optional) Map of model name to tokens per minute limit for that model. -* `allowed_passthrough_routes` - (Optional) List of pass-through routes this team is allowed to call. +* `allowed_passthrough_routes` - (Optional) List of pass-through routes this team is allowed to call. Requires a LiteLLM Enterprise license on the proxy. * `rpm_limit_type` - (Optional) How the RPM limit is enforced: `guaranteed_throughput` or `best_effort_throughput`. Changing this forces a new team to be created. diff --git a/terraform/provider/litellm/resource_team.go b/terraform/provider/litellm/resource_team.go index 3f256da36d4..1aa9660909d 100644 --- a/terraform/provider/litellm/resource_team.go +++ b/terraform/provider/litellm/resource_team.go @@ -123,6 +123,7 @@ func ResourceLiteLLMTeam() *schema.Resource { "team_member_budget_duration": { Type: schema.TypeString, Optional: true, + Computed: true, }, "team_member_rpm_limit": { Type: schema.TypeInt, @@ -268,16 +269,23 @@ func resourceLiteLLMTeamRead(d *schema.ResourceData, m interface{}) error { if teamResp.Prompts != nil { d.Set("prompts", teamResp.Prompts) } - if teamResp.TeamMemberBudget != nil { - d.Set("team_member_budget", *teamResp.TeamMemberBudget) + memberBudget := teamResp.TeamMemberBudgetTable + if memberBudget == nil { + memberBudget = &TeamMemberBudgetTable{} } - d.Set("team_member_budget_duration", GetStringValue(teamResp.TeamMemberBudgetDuration, d.Get("team_member_budget_duration").(string))) - if teamResp.TeamMemberRPMLimit != nil { - d.Set("team_member_rpm_limit", *teamResp.TeamMemberRPMLimit) + if teamResp.TeamMemberBudget == nil { + teamResp.TeamMemberBudget = memberBudget.MaxBudget } - if teamResp.TeamMemberTPMLimit != nil { - d.Set("team_member_tpm_limit", *teamResp.TeamMemberTPMLimit) + if teamResp.TeamMemberRPMLimit == nil { + teamResp.TeamMemberRPMLimit = memberBudget.RPMLimit } + if teamResp.TeamMemberTPMLimit == nil { + teamResp.TeamMemberTPMLimit = memberBudget.TPMLimit + } + d.Set("team_member_budget", teamResp.TeamMemberBudget) + d.Set("team_member_budget_duration", GetStringValue(teamResp.TeamMemberBudgetDuration, memberBudget.BudgetDuration)) + d.Set("team_member_rpm_limit", teamResp.TeamMemberRPMLimit) + d.Set("team_member_tpm_limit", teamResp.TeamMemberTPMLimit) d.Set("team_member_key_duration", GetStringValue(teamResp.TeamMemberKeyDuration, d.Get("team_member_key_duration").(string))) if v := teamModelLimit(teamResp.ModelRPMLimit, teamResp.Metadata, "model_rpm_limit"); v != nil { d.Set("model_rpm_limit", v) @@ -287,12 +295,10 @@ func resourceLiteLLMTeamRead(d *schema.ResourceData, m interface{}) error { } if teamResp.AllowedPassthroughRoutes != nil { d.Set("allowed_passthrough_routes", teamResp.AllowedPassthroughRoutes) + } else { + d.Set("allowed_passthrough_routes", toStringSlice(teamResp.Metadata["allowed_passthrough_routes"])) } - var memberModelMaxBudget map[string]interface{} - if teamResp.TeamMemberBudgetTable != nil { - memberModelMaxBudget = teamResp.TeamMemberBudgetTable.ModelMaxBudget - } - d.Set("team_member_model_max_budget", keyModelMaxBudgetJSON(memberModelMaxBudget)) + d.Set("team_member_model_max_budget", keyModelMaxBudgetJSON(memberBudget.ModelMaxBudget)) // Explicitly fetch the current permissions from the API permResp, err := getTeamPermissions(client, d.Id()) @@ -468,6 +474,16 @@ func buildTeamData(d *schema.ResourceData, teamID string) map[string]interface{} } } + // The proxy treats a missing field as "leave unchanged", so a removed value is cleared with an explicit null. + for _, key := range []string{"team_member_budget", "team_member_rpm_limit", "team_member_tpm_limit"} { + if _, ok := d.GetOk(key); !ok && d.HasChange(key) { + teamData[key] = nil + } + } + if _, ok := d.GetOk("allowed_passthrough_routes"); !ok && d.HasChange("allowed_passthrough_routes") { + teamData["allowed_passthrough_routes"] = []string{} + } + for _, key := range []string{"model_rpm_limit", "model_tpm_limit"} { if v, ok := d.GetOk(key); ok || d.HasChange(key) { teamData[key] = v @@ -531,7 +547,7 @@ func splitTeamMetadata(raw map[string]interface{}) (map[string]string, []string, tags = toStringSlice(v) case "soft_budget_alerting_emails": alertEmails = toStringSlice(v) - case "team_member_budget_id": + case "team_member_budget_id", "allowed_passthrough_routes": default: if s, ok := v.(string); ok { metadata[k] = s diff --git a/terraform/provider/litellm/resource_team_test.go b/terraform/provider/litellm/resource_team_test.go index d9d09c13dda..917cb0096a0 100644 --- a/terraform/provider/litellm/resource_team_test.go +++ b/terraform/provider/litellm/resource_team_test.go @@ -707,3 +707,177 @@ func TestTeamReadWithoutMemberBudgetTableLeavesFieldEmpty(t *testing.T) { t.Errorf("team_member_model_max_budget = %q, want empty", got) } } + +func TestTeamReadMapsMemberBudgetFieldsFromBudgetTable(t *testing.T) { + var captured map[string]interface{} + srv := newTeamTestServer(t, &captured, `{ + "team_id": "team-1", + "team_info": { + "team_id": "team-1", + "team_alias": "eng", + "metadata": {"team_member_budget_id": "budget-1"}, + "team_member_budget_table": { + "max_budget": 250.0, + "budget_duration": "30d", + "rpm_limit": 60, + "tpm_limit": 100000 + } + } + }`) + defer srv.Close() + + d := newTeamResourceData(t, map[string]interface{}{"team_alias": "eng"}) + d.SetId("team-1") + if err := resourceLiteLLMTeamRead(d, NewClient(srv.URL, "test-key", true)); err != nil { + t.Fatalf("read returned error: %v", err) + } + if got := d.Get("team_member_budget").(float64); got != 250.0 { + t.Errorf("team_member_budget = %v, want 250", got) + } + if got := d.Get("team_member_budget_duration").(string); got != "30d" { + t.Errorf("team_member_budget_duration = %q, want 30d", got) + } + if got := d.Get("team_member_rpm_limit").(int); got != 60 { + t.Errorf("team_member_rpm_limit = %v, want 60", got) + } + if got := d.Get("team_member_tpm_limit").(int); got != 100000 { + t.Errorf("team_member_tpm_limit = %v, want 100000", got) + } +} + +func TestTeamReadClearsMemberBudgetFieldsWhenBudgetTableNull(t *testing.T) { + var captured map[string]interface{} + srv := newTeamTestServer(t, &captured, `{"team_id":"team-1","team_info":{"team_id":"team-1","team_alias":"eng","team_member_budget_table":{"max_budget":null,"rpm_limit":null,"tpm_limit":null}}}`) + defer srv.Close() + + d := newTeamResourceData(t, map[string]interface{}{ + "team_alias": "eng", + "team_member_budget": 250.0, + "team_member_rpm_limit": 60, + "team_member_tpm_limit": 100000, + }) + d.SetId("team-1") + if err := resourceLiteLLMTeamRead(d, NewClient(srv.URL, "test-key", true)); err != nil { + t.Fatalf("read returned error: %v", err) + } + for _, key := range []string{"team_member_budget", "team_member_rpm_limit", "team_member_tpm_limit"} { + if _, ok := d.GetOk(key); ok { + t.Errorf("%s = %v, want unset so drift shows up", key, d.Get(key)) + } + } +} + +func TestTeamMemberBudgetDurationInheritedByProxyIsNotDrift(t *testing.T) { + res := ResourceLiteLLMTeam() + priorData := schema.TestResourceDataRaw(t, res.Schema, map[string]interface{}{ + "team_alias": "eng", + "budget_duration": "7d", + "team_member_budget": 250.0, + "team_member_budget_duration": "7d", + }) + priorData.SetId("team-1") + config := terraform.NewResourceConfigRaw(map[string]interface{}{ + "team_alias": "eng", + "budget_duration": "7d", + "team_member_budget": 250.0, + }) + diff, err := res.Diff(context.Background(), priorData.State(), config, nil) + if err != nil { + t.Fatalf("diff failed: %v", err) + } + if diff != nil && len(diff.Attributes) > 0 { + t.Fatalf("a member budget duration copied from the team's budget_duration must not plan a change, diff = %+v", diff.Attributes) + } +} + +func TestTeamReadMapsAllowedPassthroughRoutesFromMetadata(t *testing.T) { + var captured map[string]interface{} + srv := newTeamTestServer(t, &captured, `{ + "team_id": "team-1", + "team_info": { + "team_id": "team-1", + "team_alias": "eng", + "metadata": {"department": "eng", "allowed_passthrough_routes": ["/vertex_ai/*"]} + } + }`) + defer srv.Close() + + d := newTeamResourceData(t, map[string]interface{}{"team_alias": "eng"}) + d.SetId("team-1") + if err := resourceLiteLLMTeamRead(d, NewClient(srv.URL, "test-key", true)); err != nil { + t.Fatalf("read returned error: %v", err) + } + if got := d.Get("allowed_passthrough_routes").([]interface{}); !reflect.DeepEqual(got, []interface{}{"/vertex_ai/*"}) { + t.Errorf("allowed_passthrough_routes = %v, want [/vertex_ai/*]", got) + } + if got := d.Get("metadata").(map[string]interface{}); !reflect.DeepEqual(got, map[string]interface{}{"department": "eng"}) { + t.Errorf("metadata = %v, want department only", got) + } +} + +func TestTeamUpdateClearsRemovedMemberBudgetFieldsAndPassthroughRoutes(t *testing.T) { + var captured map[string]interface{} + srv := newTeamTestServer(t, &captured, `{"team_id":"team-1","team_info":{"team_id":"team-1","team_alias":"eng"}}`) + defer srv.Close() + + res := ResourceLiteLLMTeam() + priorData := schema.TestResourceDataRaw(t, res.Schema, map[string]interface{}{ + "team_alias": "eng", + "team_member_budget": 250.0, + "team_member_rpm_limit": 60, + "team_member_tpm_limit": 100000, + "allowed_passthrough_routes": []interface{}{"/vertex_ai/*"}, + }) + priorData.SetId("team-1") + prior := priorData.State() + config := terraform.NewResourceConfigRaw(map[string]interface{}{"team_alias": "eng"}) + diff, err := res.Diff(context.Background(), prior, config, nil) + if err != nil { + t.Fatalf("diff failed: %v", err) + } + d, err := schema.InternalMap(res.Schema).Data(prior, diff) + if err != nil { + t.Fatalf("data failed: %v", err) + } + + if err := resourceLiteLLMTeamUpdate(d, NewClient(srv.URL, "test-key", true)); err != nil { + t.Fatalf("update failed: %v", err) + } + for _, key := range []string{"team_member_budget", "team_member_rpm_limit", "team_member_tpm_limit"} { + if got, ok := captured[key]; !ok || got != nil { + t.Errorf("payload %s = %v (present=%v), want explicit null", key, got, ok) + } + } + if got := captured["allowed_passthrough_routes"]; !reflect.DeepEqual(got, []interface{}{}) { + t.Errorf("payload allowed_passthrough_routes = %v, want []", got) + } +} + +func TestTeamUpdateOmitsUnchangedUnsetMemberBudgetFields(t *testing.T) { + var captured map[string]interface{} + srv := newTeamTestServer(t, &captured, `{"team_id":"team-1","team_info":{"team_id":"team-1","team_alias":"eng"}}`) + defer srv.Close() + + res := ResourceLiteLLMTeam() + priorData := schema.TestResourceDataRaw(t, res.Schema, map[string]interface{}{"team_alias": "eng"}) + priorData.SetId("team-1") + prior := priorData.State() + config := terraform.NewResourceConfigRaw(map[string]interface{}{"team_alias": "eng-renamed"}) + diff, err := res.Diff(context.Background(), prior, config, nil) + if err != nil { + t.Fatalf("diff failed: %v", err) + } + d, err := schema.InternalMap(res.Schema).Data(prior, diff) + if err != nil { + t.Fatalf("data failed: %v", err) + } + + if err := resourceLiteLLMTeamUpdate(d, NewClient(srv.URL, "test-key", true)); err != nil { + t.Fatalf("update failed: %v", err) + } + for _, key := range []string{"team_member_budget", "team_member_rpm_limit", "team_member_tpm_limit", "allowed_passthrough_routes"} { + if _, ok := captured[key]; ok { + t.Errorf("payload has %s = %v, want it omitted when never set", key, captured[key]) + } + } +} diff --git a/terraform/provider/litellm/types.go b/terraform/provider/litellm/types.go index aff90cd64be..77483170b3b 100644 --- a/terraform/provider/litellm/types.go +++ b/terraform/provider/litellm/types.go @@ -76,8 +76,12 @@ type TeamResponse struct { TeamMemberBudgetTable *TeamMemberBudgetTable `json:"team_member_budget_table,omitempty"` } -// TeamMemberBudgetTable is the team's default member budget row, the only place /team/info returns team_member_model_max_budget. +// TeamMemberBudgetTable is the team's default member budget row, the only place /team/info returns the team_member_* budget and limits. type TeamMemberBudgetTable struct { + MaxBudget *float64 `json:"max_budget,omitempty"` + BudgetDuration string `json:"budget_duration,omitempty"` + RPMLimit *int `json:"rpm_limit,omitempty"` + TPMLimit *int `json:"tpm_limit,omitempty"` ModelMaxBudget map[string]interface{} `json:"model_max_budget,omitempty"` }