diff --git a/terraform/provider/CHANGELOG.md b/terraform/provider/CHANGELOG.md index 06a39d8da20..ee5b42fe0b7 100644 --- a/terraform/provider/CHANGELOG.md +++ b/terraform/provider/CHANGELOG.md @@ -16,6 +16,7 @@ longer signal it. ### Added +- **team_member_add**: `tpm_limit`, `rpm_limit`, `budget_duration`, and `allowed_models` attributes on `litellm_team_member_add`, applied to every member of the resource; `budget_duration` and `allowed_models` ride on `/team/member_add`, while the limits are sent through `/team/member_update`, which is where the proxy accepts them - **team**: Optional `team_id` argument on `litellm_team`, so teams can be created with a stable, human-readable ID instead of a provider-generated UUID; changing it forces replacement - **jwt_key_mapping**: New `litellm_jwt_key_mapping` resource for the proxy's JWT to virtual key mappings, so JWT clients identified by a claim (`client_id`, `azp`, `sub`) map to virtual keys and inherit their models, budgets and rate limits. Supports `description` and `is_active`, rotating the mapped key in place, and forces replacement when the claim name or value changes - **team**: `soft_budget`, `tags`, and `soft_budget_alerting_emails` attributes on `litellm_team`, matching what `/team/new` and `/team/update` already accept; `soft_budget_alerting_emails` is sent under `metadata`, where the proxy reads it diff --git a/terraform/provider/docs/resources/team_member_add.md b/terraform/provider/docs/resources/team_member_add.md index f5398e49d9c..bad241cddec 100644 --- a/terraform/provider/docs/resources/team_member_add.md +++ b/terraform/provider/docs/resources/team_member_add.md @@ -27,6 +27,10 @@ resource "litellm_team_member_add" "example" { } max_budget_in_team = 100.0 + budget_duration = "30d" + tpm_limit = 100000 + rpm_limit = 100 + allowed_models = ["gpt-4"] } ``` @@ -152,6 +156,12 @@ resource "litellm_team_member_add" "budget_example" { * `user_email` - (Optional) The email of the user to add to the team. * `role` - (Required) The role of the user in the team. Must be one of: "admin" or "user". * `max_budget_in_team` - (Optional) The maximum budget allocated for the team members. +* `budget_duration` - (Optional) Duration after which each member's budget resets, for example "1h", "24h", "7d", "30d". If not set, the budget never resets. +* `tpm_limit` - (Optional) Tokens per minute limit applied to each team member. Sent via `/team/member_update` after members are added, since `/team/member_add` does not accept it. +* `rpm_limit` - (Optional) Requests per minute limit applied to each team member. Sent via `/team/member_update` after members are added, since `/team/member_add` does not accept it. +* `allowed_models` - (Optional) List of models each team member can access. If not set, members inherit the team's `default_team_member_models` or all team models. + +Removing `budget_duration`, `tpm_limit`, `rpm_limit`, or `allowed_models` from the configuration clears that setting on every member through `/team/member_update`. ## Import diff --git a/terraform/provider/litellm/resource_team_member_add.go b/terraform/provider/litellm/resource_team_member_add.go index da5c7a6ebd7..ca3541408ba 100644 --- a/terraform/provider/litellm/resource_team_member_add.go +++ b/terraform/provider/litellm/resource_team_member_add.go @@ -49,10 +49,105 @@ func resourceLiteLLMTeamMemberAdd() *schema.Resource { Type: schema.TypeFloat, Optional: true, }, + "tpm_limit": { + Type: schema.TypeInt, + Optional: true, + }, + "rpm_limit": { + Type: schema.TypeInt, + Optional: true, + }, + "budget_duration": { + Type: schema.TypeString, + Optional: true, + }, + "allowed_models": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, }, } } +func expandAllowedModels(raw []interface{}) []string { + models := make([]string, 0, len(raw)) + for _, m := range raw { + models = append(models, m.(string)) + } + return models +} + +func applyAddOnlySettings(d *schema.ResourceData, payload map[string]interface{}) { + if v, ok := d.GetOk("budget_duration"); ok { + payload["budget_duration"] = v.(string) + } + if v, ok := d.GetOk("allowed_models"); ok { + payload["allowed_models"] = expandAllowedModels(v.([]interface{})) + } +} + +func applyLimits(d *schema.ResourceData, payload map[string]interface{}) { + for _, key := range []string{"tpm_limit", "rpm_limit"} { + if v, ok := d.GetOk(key); ok { + payload[key] = v.(int) + } + } +} + +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"} { + if _, ok := d.GetOk(key); !ok && d.HasChange(key) { + payload[key] = nil + } + } + if _, ok := d.GetOk("allowed_models"); !ok && d.HasChange("allowed_models") { + payload["allowed_models"] = []string{} + } +} + +func memberIdentity(member map[string]interface{}, payload map[string]interface{}) { + if userID, ok := member["user_id"].(string); ok && userID != "" { + payload["user_id"] = userID + } + if userEmail, ok := member["user_email"].(string); ok && userEmail != "" { + payload["user_email"] = userEmail + } +} + +// tpm/rpm limits are only accepted by /team/member_update, not /team/member_add +func setMemberLimits(client *Client, d *schema.ResourceData, teamID string, members []map[string]interface{}) error { + limits := map[string]interface{}{} + applyLimits(d, limits) + if len(limits) == 0 { + return nil + } + for _, member := range members { + updateData := map[string]interface{}{ + "team_id": teamID, + } + for k, v := range limits { + updateData[k] = v + } + memberIdentity(member, updateData) + + log.Printf("[DEBUG] Set team member limits request payload: %+v", updateData) + + resp, err := MakeRequest(client, "POST", "/team/member_update", updateData) + if err != nil { + return fmt.Errorf("error setting team member limits: %v", err) + } + defer resp.Body.Close() + + if err := handleResponse(resp, "setting team member limits"); err != nil { + return err + } + } + return nil +} + func resourceLiteLLMTeamMemberAddCreate(d *schema.ResourceData, m interface{}) error { client := m.(*Client) @@ -81,6 +176,7 @@ func resourceLiteLLMTeamMemberAddCreate(d *schema.ResourceData, m interface{}) e "team_id": teamID, "max_budget_in_team": maxBudget, } + applyAddOnlySettings(d, memberData) log.Printf("[DEBUG] Create team members request payload: %+v", memberData) @@ -94,9 +190,12 @@ func resourceLiteLLMTeamMemberAddCreate(d *schema.ResourceData, m interface{}) e return err } - // Set ID as team_id since this resource manages all members for a team d.SetId(teamID) + if err := setMemberLimits(client, d, teamID, membersList); err != nil { + return err + } + return resourceLiteLLMTeamMemberAddRead(d, m) } @@ -140,11 +239,13 @@ func resourceLiteLLMTeamMemberAddUpdate(d *schema.ResourceData, m interface{}) e // Track which members have been updated to avoid duplicates updatedMembers := make(map[string]bool) - // Check if max_budget_in_team has changed - if d.HasChange("max_budget_in_team") { - log.Printf("[DEBUG] max_budget_in_team changed, updating all existing members with new budget: %f", maxBudget) + // Check if any team-wide member setting has changed + settingsChanged := d.HasChange("max_budget_in_team") || d.HasChange("tpm_limit") || d.HasChange("rpm_limit") || + d.HasChange("budget_duration") || d.HasChange("allowed_models") + if settingsChanged { + log.Printf("[DEBUG] Member settings changed, updating all existing members") - // Update ALL existing members with the new budget + // Update ALL existing members with the new settings for key, newMember := range newMemberMap { if _, exists := oldMemberMap[key]; exists { updateData := map[string]interface{}{ @@ -152,22 +253,18 @@ func resourceLiteLLMTeamMemberAddUpdate(d *schema.ResourceData, m interface{}) e "role": newMember["role"].(string), "max_budget_in_team": maxBudget, } - if userID, ok := newMember["user_id"].(string); ok && userID != "" { - updateData["user_id"] = userID - } - if userEmail, ok := newMember["user_email"].(string); ok && userEmail != "" { - updateData["user_email"] = userEmail - } + applyUpdateSettings(d, updateData) + memberIdentity(newMember, updateData) - log.Printf("[DEBUG] Update team member budget request payload: %+v", updateData) + log.Printf("[DEBUG] Update team member settings request payload: %+v", updateData) resp, err := MakeRequest(client, "POST", "/team/member_update", updateData) if err != nil { - return fmt.Errorf("error updating team member budget: %v", err) + return fmt.Errorf("error updating team member settings: %v", err) } defer resp.Body.Close() - if err := handleResponse(resp, "updating team member budget"); err != nil { + if err := handleResponse(resp, "updating team member settings"); err != nil { return err } @@ -220,12 +317,8 @@ func resourceLiteLLMTeamMemberAddUpdate(d *schema.ResourceData, m interface{}) e "role": newMember["role"].(string), "max_budget_in_team": maxBudget, } - if userID, ok := newMember["user_id"].(string); ok && userID != "" { - updateData["user_id"] = userID - } - if userEmail, ok := newMember["user_email"].(string); ok && userEmail != "" { - updateData["user_email"] = userEmail - } + applyUpdateSettings(d, updateData) + memberIdentity(newMember, updateData) log.Printf("[DEBUG] Update team member request payload: %+v", updateData) @@ -265,6 +358,7 @@ func resourceLiteLLMTeamMemberAddUpdate(d *schema.ResourceData, m interface{}) e "team_id": teamID, "max_budget_in_team": maxBudget, } + applyAddOnlySettings(d, memberData) log.Printf("[DEBUG] Adding new team members request payload: %+v", memberData) @@ -277,6 +371,10 @@ func resourceLiteLLMTeamMemberAddUpdate(d *schema.ResourceData, m interface{}) e if err := handleResponse(resp, "adding team members"); err != nil { return err } + + if err := setMemberLimits(client, d, teamID, membersToAdd); err != nil { + return err + } } return resourceLiteLLMTeamMemberAddRead(d, m) diff --git a/terraform/provider/litellm/resource_team_member_add_test.go b/terraform/provider/litellm/resource_team_member_add_test.go new file mode 100644 index 00000000000..a2ddb0016bc --- /dev/null +++ b/terraform/provider/litellm/resource_team_member_add_test.go @@ -0,0 +1,274 @@ +package litellm + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestTeamMemberAddCreateSendsMemberSettings(t *testing.T) { + var addPayload map[string]interface{} + var updatePayloads []map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var payload map[string]interface{} + json.Unmarshal(body, &payload) + switch r.URL.Path { + case "/team/member_add": + addPayload = payload + case "/team/member_update": + updatePayloads = append(updatePayloads, payload) + default: + t.Errorf("unexpected request path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := schema.TestResourceDataRaw(t, resourceLiteLLMTeamMemberAdd().Schema, map[string]interface{}{ + "team_id": "team-1", + "member": []interface{}{ + map[string]interface{}{ + "user_id": "user-1", + "role": "user", + }, + }, + "max_budget_in_team": 25.0, + "tpm_limit": 1000, + "rpm_limit": 10, + "budget_duration": "30d", + "allowed_models": []interface{}{"claude-opus-4-6-v1"}, + }) + + if err := resourceLiteLLMTeamMemberAddCreate(d, client); err != nil { + t.Fatalf("create failed: %v", err) + } + + if addPayload["budget_duration"] != "30d" { + t.Fatalf("member_add payload sent budget_duration %v, want 30d", addPayload["budget_duration"]) + } + 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) + } + if _, ok := addPayload["tpm_limit"]; ok { + t.Fatalf("member_add payload must not carry tpm_limit, got %v", addPayload["tpm_limit"]) + } + + if len(updatePayloads) != 1 { + t.Fatalf("expected 1 member_update call for limits, got %d", len(updatePayloads)) + } + update := updatePayloads[0] + if update["tpm_limit"] != float64(1000) { + t.Fatalf("member_update payload sent tpm_limit %v, want 1000", update["tpm_limit"]) + } + if update["rpm_limit"] != float64(10) { + t.Fatalf("member_update payload sent rpm_limit %v, want 10", update["rpm_limit"]) + } + if update["user_id"] != "user-1" { + t.Fatalf("member_update payload sent user_id %v, want user-1", update["user_id"]) + } +} + +func TestTeamMemberAddCreateOmitsUnsetSettings(t *testing.T) { + var addPayload map[string]interface{} + updateCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + switch r.URL.Path { + case "/team/member_add": + json.Unmarshal(body, &addPayload) + case "/team/member_update": + updateCalls++ + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := schema.TestResourceDataRaw(t, resourceLiteLLMTeamMemberAdd().Schema, map[string]interface{}{ + "team_id": "team-1", + "member": []interface{}{ + map[string]interface{}{ + "user_id": "user-1", + "role": "user", + }, + }, + }) + + if err := resourceLiteLLMTeamMemberAddCreate(d, client); err != nil { + t.Fatalf("create failed: %v", err) + } + + for _, field := range []string{"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]) + } + } + if updateCalls != 0 { + t.Fatalf("expected no member_update calls without limits, got %d", updateCalls) + } +} + +func TestTeamMemberAddCreateSetsIDBeforeLimitsFail(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/team/member_update" { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":"boom"}`)) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := schema.TestResourceDataRaw(t, resourceLiteLLMTeamMemberAdd().Schema, map[string]interface{}{ + "team_id": "team-1", + "member": []interface{}{ + map[string]interface{}{ + "user_id": "user-1", + "role": "user", + }, + }, + "tpm_limit": 1000, + }) + + if err := resourceLiteLLMTeamMemberAddCreate(d, client); err == nil { + t.Fatal("create should fail when member_update fails") + } + if d.Id() != "team-1" { + t.Fatalf("resource ID = %q after failed limits call, want team-1 so Terraform can taint and recreate it", d.Id()) + } +} + +// newTeamMemberUpdateResourceData builds a ResourceData with one member in state +// and a real old -> new diff on the scalar settings, so d.HasChange and d.GetOk +// behave as they do during a real Update call +func newTeamMemberUpdateResourceData(t *testing.T, old, new map[string]string) *schema.ResourceData { + t.Helper() + attrs := map[string]string{ + "team_id": "team-1", + "member.#": "1", + "member.1.user_id": "user-1", + "member.1.user_email": "", + "member.1.role": "user", + "allowed_models.#": "0", + "max_budget_in_team": "25", + } + 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", Attributes: attrs} + d, err := schema.InternalMap(resourceLiteLLMTeamMemberAdd().Schema).Data(state, &terraform.InstanceDiff{Attributes: diffAttrs}) + if err != nil { + t.Fatalf("building ResourceData returned error: %v", err) + } + return d +} + +func runTeamMemberUpdate(t *testing.T, d *schema.ResourceData) []map[string]interface{} { + t.Helper() + var updatePayloads []map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/team/member_update" { + t.Errorf("unexpected request path: %s", r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + var payload map[string]interface{} + json.Unmarshal(body, &payload) + updatePayloads = append(updatePayloads, payload) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + })) + defer srv.Close() + + if err := resourceLiteLLMTeamMemberAddUpdate(d, NewClient(srv.URL, "test-key", true)); err != nil { + t.Fatalf("update failed: %v", err) + } + if len(updatePayloads) != 1 { + t.Fatalf("expected 1 member_update call, got %d", len(updatePayloads)) + } + return updatePayloads +} + +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"}, + ) + + update := runTeamMemberUpdate(t, d)[0] + 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"]) + } + if update["budget_duration"] != "7d" { + t.Fatalf("member_update payload budget_duration = %v, want 7d", update["budget_duration"]) + } + if !reflect.DeepEqual(update["allowed_models"], []interface{}{"gpt-5.2"}) { + t.Fatalf("member_update payload allowed_models = %v, want [gpt-5.2]", update["allowed_models"]) + } + if update["user_id"] != "user-1" { + t.Fatalf("member_update payload user_id = %v, want user-1", update["user_id"]) + } +} + +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{"allowed_models.#": "0"}, + ) + + update := runTeamMemberUpdate(t, d)[0] + for _, field := range []string{"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) + } + if v != nil { + t.Fatalf("member_update payload %s = %v, want explicit null", field, v) + } + } + if !reflect.DeepEqual(update["allowed_models"], []interface{}{}) { + t.Fatalf("member_update payload allowed_models = %v, want empty list", update["allowed_models"]) + } +} + +func TestTeamMemberAddUpdateLeavesUnchangedSettingsAlone(t *testing.T) { + d := newTeamMemberUpdateResourceData(t, + map[string]string{"budget_duration": "30d"}, + map[string]string{"budget_duration": "7d"}, + ) + + update := runTeamMemberUpdate(t, d)[0] + for _, field := range []string{"tpm_limit", "rpm_limit"} { + if v, present := update[field]; present { + t.Fatalf("member_update payload must not touch never-set %s, got %v", field, v) + } + } + if _, present := update["allowed_models"]; present { + t.Fatalf("member_update payload must not touch unchanged allowed_models, got %v", update["allowed_models"]) + } +}