mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
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:
parent
5bcd494e88
commit
9b6b5eb36b
4 changed files with 273 additions and 19 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
151
terraform/provider/litellm/resource_team_member_add_test.go
Normal file
151
terraform/provider/litellm/resource_team_member_add_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue