feat(terraform): add tpm_limit, rpm_limit, budget_duration, allowed_models to litellm_team_member_add

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-08-28 17:38:20 +00:00
parent 5bcd494e88
commit 9b6b5eb36b
4 changed files with 273 additions and 19 deletions

View file

@ -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**: `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
### Fixed

View file

@ -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,10 @@ 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.
## Import

View file

@ -49,10 +49,100 @@ 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 applyUpdateSettings(d *schema.ResourceData, payload map[string]interface{}) {
applyAddOnlySettings(d, payload)
if v, ok := d.GetOk("tpm_limit"); ok {
payload["tpm_limit"] = v.(int)
}
if v, ok := d.GetOk("rpm_limit"); ok {
payload["rpm_limit"] = v.(int)
}
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 {
_, tpmSet := d.GetOk("tpm_limit")
_, rpmSet := d.GetOk("rpm_limit")
if !tpmSet && !rpmSet {
return nil
}
for _, member := range members {
updateData := map[string]interface{}{
"team_id": teamID,
}
if v, ok := d.GetOk("tpm_limit"); ok {
updateData["tpm_limit"] = v.(int)
}
if v, ok := d.GetOk("rpm_limit"); ok {
updateData["rpm_limit"] = v.(int)
}
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 +171,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,6 +185,10 @@ func resourceLiteLLMTeamMemberAddCreate(d *schema.ResourceData, m interface{}) e
return err
}
if err := setMemberLimits(client, d, teamID, membersList); err != nil {
return err
}
// Set ID as team_id since this resource manages all members for a team
d.SetId(teamID)
@ -140,11 +235,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 +249,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 +313,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 +354,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 +367,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)

View file

@ -0,0 +1,151 @@
package litellm
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
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 TestApplyUpdateSettingsCarriesAllFields(t *testing.T) {
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": 500,
"rpm_limit": 5,
"budget_duration": "7d",
"allowed_models": []interface{}{"gpt-5.2", "claude-opus-4-6-v1"},
})
payload := map[string]interface{}{"team_id": "team-1"}
applyUpdateSettings(d, payload)
if payload["tpm_limit"] != 500 || payload["rpm_limit"] != 5 {
t.Fatalf("payload limits = %v/%v, want 500/5", payload["tpm_limit"], payload["rpm_limit"])
}
if payload["budget_duration"] != "7d" {
t.Fatalf("payload budget_duration = %v, want 7d", payload["budget_duration"])
}
wantModels := []string{"gpt-5.2", "claude-opus-4-6-v1"}
if !reflect.DeepEqual(payload["allowed_models"], wantModels) {
t.Fatalf("payload allowed_models = %v, want %v", payload["allowed_models"], wantModels)
}
}