Merge pull request #38682 from BerriAI/devin_ai_1787937848_tf_team_member_fields

feat(terraform): add tpm_limit, rpm_limit, budget_duration, allowed_models to litellm_team_member_add
This commit is contained in:
Yassin Kortam 2026-09-15 14:12:25 -07:00 committed by GitHub
commit d35af8d302
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 403 additions and 20 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**: 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

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,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

View file

@ -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)

View file

@ -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"])
}
}