feat(terraform): allow custom team_id on litellm_team (#40459)

Add an optional, computed, force-new team_id argument to the
litellm_team resource. When set, it is sent to /team/new as the
team_id; when omitted the provider keeps generating a UUID. Read
mirrors the resource id into state so import and plan stay clean.

Resolves LIT-6399

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-09 19:45:02 -07:00 committed by GitHub
parent 4ddc5e2c29
commit 18eb6d7998
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 113 additions and 3 deletions

View file

@ -16,6 +16,7 @@ longer signal it.
### Added
- **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
- **user**: New `litellm_user` resource and `litellm_user` / `litellm_users` data sources for managing internal users

View file

@ -14,6 +14,16 @@ resource "litellm_team" "engineering" {
}
```
### Team with a Custom ID
```hcl
resource "litellm_team" "platform" {
team_id = "platform-team"
team_alias = "platform"
models = ["gpt-4-proxy"]
}
```
### Team with Comprehensive Configuration
```hcl
@ -92,6 +102,8 @@ resource "litellm_team" "model_dependent_team" {
The following arguments are supported:
* `team_id` - (Optional) A stable, human-readable ID for the team (for example `platform-team`). If omitted, the provider generates a random UUID. Changing this forces a new team to be created.
* `team_alias` - (Required) A human-readable identifier for the team.
* `organization_id` - (Optional) The ID of the organization this team belongs to.
@ -152,7 +164,7 @@ The following arguments are supported:
In addition to the arguments above, the following attributes are exported:
* `id` - The unique identifier for the team.
* `id` - The unique identifier for the team, equal to `team_id`.
## Import
@ -162,7 +174,7 @@ Teams can be imported using the team ID:
terraform import litellm_team.engineering <team-id>
```
Note: The team ID is generated when the team is created and is different from the `team_alias`.
Note: Unless `team_id` is set, the team ID is generated when the team is created and is different from the `team_alias`.
## Note on Team Members

View file

@ -31,6 +31,13 @@ func ResourceLiteLLMTeam() *schema.Resource {
},
Schema: map[string]*schema.Schema{
"team_id": {
Type: schema.TypeString,
Optional: true,
Computed: true,
ForceNew: true,
Description: "Unique ID for the team. Generated by the provider if not provided",
},
"team_alias": {
Type: schema.TypeString,
Required: true,
@ -162,7 +169,7 @@ func ResourceLiteLLMTeam() *schema.Resource {
func resourceLiteLLMTeamCreate(d *schema.ResourceData, m interface{}) error {
client := m.(*Client)
teamID := uuid.New().String()
teamID := resolveTeamID(d)
teamData := buildTeamData(d, teamID)
// Throughput limit types are only accepted by /team/new, not /team/update.
@ -214,6 +221,7 @@ func resourceLiteLLMTeamRead(d *schema.ResourceData, m interface{}) error {
teamResp := infoResp.TeamInfo
// Update the state with values from the response or fall back to the data passed in during creation
d.Set("team_id", d.Id())
d.Set("team_alias", GetStringValue(teamResp.TeamAlias, d.Get("team_alias").(string)))
d.Set("organization_id", GetStringValue(teamResp.OrganizationID, d.Get("organization_id").(string)))
@ -354,6 +362,13 @@ func resourceLiteLLMTeamDelete(d *schema.ResourceData, m interface{}) error {
return nil
}
func resolveTeamID(d *schema.ResourceData) string {
if v, ok := d.GetOk("team_id"); ok {
return v.(string)
}
return uuid.New().String()
}
func buildTeamData(d *schema.ResourceData, teamID string) map[string]interface{} {
teamData := map[string]interface{}{
"team_id": teamID,

View file

@ -9,6 +9,7 @@ import (
"reflect"
"testing"
"github.com/google/uuid"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
)
@ -85,6 +86,87 @@ func TestTeamCreateSendsSoftBudgetTagsAndAlertEmails(t *testing.T) {
}
}
func TestTeamCreateSendsConfiguredTeamID(t *testing.T) {
var captured map[string]interface{}
srv := newTeamTestServer(t, &captured, `{"team_id":"platform-team","team_info":{"team_id":"platform-team","team_alias":"platform"},"keys":[],"team_memberships":[]}`)
defer srv.Close()
d := newTeamResourceData(t, map[string]interface{}{
"team_id": "platform-team",
"team_alias": "platform",
})
if err := resourceLiteLLMTeamCreate(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("create failed: %v", err)
}
if got := captured["team_id"]; got != "platform-team" {
t.Fatalf("payload team_id = %v, want platform-team", got)
}
if got := d.Id(); got != "platform-team" {
t.Fatalf("resource id = %q, want platform-team", got)
}
if got := d.Get("team_id"); got != "platform-team" {
t.Fatalf("state team_id = %v, want platform-team", got)
}
}
func TestTeamCreateGeneratesTeamIDWhenUnset(t *testing.T) {
var captured map[string]interface{}
srv := newTeamTestServer(t, &captured, `{"team_id":"x","team_info":{"team_alias":"eng"},"keys":[],"team_memberships":[]}`)
defer srv.Close()
d := newTeamResourceData(t, map[string]interface{}{"team_alias": "eng"})
if err := resourceLiteLLMTeamCreate(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("create failed: %v", err)
}
sent, _ := captured["team_id"].(string)
if _, err := uuid.Parse(sent); err != nil {
t.Fatalf("payload team_id = %q, want a generated UUID: %v", sent, err)
}
if d.Id() != sent || d.Get("team_id") != sent {
t.Fatalf("id = %q, state team_id = %v, want both to equal the sent id %q", d.Id(), d.Get("team_id"), sent)
}
}
func TestTeamReadSetsTeamIDFromResourceID(t *testing.T) {
var captured map[string]interface{}
srv := newTeamTestServer(t, &captured, `{"team_id":"imported-team","team_info":{"team_id":"imported-team","team_alias":"imported"},"keys":[],"team_memberships":[]}`)
defer srv.Close()
d := newTeamResourceData(t, map[string]interface{}{})
d.SetId("imported-team")
if err := resourceLiteLLMTeamRead(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("read failed: %v", err)
}
if got := d.Get("team_id"); got != "imported-team" {
t.Fatalf("team_id = %v, want imported-team", got)
}
}
func TestTeamIDChangeForcesReplacement(t *testing.T) {
res := ResourceLiteLLMTeam()
priorData := schema.TestResourceDataRaw(t, res.Schema, map[string]interface{}{
"team_id": "old-team",
"team_alias": "eng",
})
priorData.SetId("old-team")
config := terraform.NewResourceConfigRaw(map[string]interface{}{
"team_id": "new-team",
"team_alias": "eng",
})
diff, err := res.Diff(context.Background(), priorData.State(), config, nil)
if err != nil {
t.Fatalf("diff failed: %v", err)
}
if diff == nil || !diff.RequiresNew() {
t.Fatalf("changing team_id must force replacement, diff = %+v", diff)
}
}
func TestTeamReadMapsTeamInfoEnvelope(t *testing.T) {
var captured map[string]interface{}
srv := newTeamTestServer(t, &captured, teamInfoWithSoftBudget)