fix(terraform): add soft_budget, tags, and soft_budget_alerting_emails to litellm_team (#37918)

* fix(terraform): add soft_budget, tags, and soft_budget_alerting_emails to litellm_team

The team resource rejected soft_budget and tags at plan time and had no way
to express the list-valued metadata.soft_budget_alerting_emails the proxy
reads for soft-budget alerts, even though /team/new and /team/update accept
all three. Add the attributes, forward them in buildTeamData (alert emails
merged under metadata, where the proxy stores them), and send the full
metadata map whenever either half changes because /team/update replaces
metadata wholesale.

Read was decoding /team/info as if the team fields were top-level, but the
proxy nests them under team_info, so every attribute silently fell back to
prior state. Decode the envelope and split the proxy's metadata back into
tags / soft_budget_alerting_emails / string metadata, dropping the
server-managed team_member_budget_id.

Verified with OpenTofu plan/apply against a live proxy: the attributes are
accepted, land on the proxy, refresh into state, re-plan clean, propagate
on update, and clear when removed from HCL.

* fix(terraform): clear litellm_team.soft_budget in state when the proxy returns null

Read only wrote soft_budget when the proxy returned a value, so a soft
budget cleared outside Terraform stayed in state and never surfaced as
drift. Set it from the response unconditionally so a null clears it.
This commit is contained in:
yuneng-jiang 2026-08-24 10:12:10 -07:00 committed by GitHub
parent f005afa146
commit a72203eae4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 300 additions and 10 deletions

View file

@ -14,6 +14,14 @@ longer signal it.
## [Unreleased] ## [Unreleased]
### Added
- **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
- **team**: Read now decodes the `team_info` envelope `/team/info` actually returns, so team attributes refresh from the proxy instead of always falling back to the prior state
### Changed ### Changed
- **Versioning**: the provider is now published at the LiteLLM version, from the same commit as the proxy, on every LiteLLM release (dev, rc, stable). The `0.x` line ends at `0.4.0`; a `~> 0.4` constraint will not receive further releases, so re-pin to the LiteLLM version your proxy runs (for example `~> 1.99.0`). Existing `0.x` versions remain in the registry and keep verifying - **Versioning**: the provider is now published at the LiteLLM version, from the same commit as the proxy, on every LiteLLM release (dev, rc, stable). The `0.x` line ends at `0.4.0`; a `~> 0.4` constraint will not receive further releases, so re-pin to the LiteLLM version your proxy runs (for example `~> 1.99.0`). Existing `0.x` versions remain in the registry and keep verifying

View file

@ -24,11 +24,18 @@ resource "litellm_team" "advanced_team" {
# Budget and rate limiting # Budget and rate limiting
max_budget = 1000.0 max_budget = 1000.0
soft_budget = 800.0
budget_duration = "1mo" budget_duration = "1mo"
tpm_limit = 500000 tpm_limit = 500000
rpm_limit = 5000 rpm_limit = 5000
blocked = false blocked = false
# Who gets paged when spend crosses soft_budget
soft_budget_alerting_emails = ["finops@example.com"]
# Tags for spend tracking and tag-based routing
tags = ["team:ai-research", "environment:production"]
# Team member permissions # Team member permissions
team_member_permissions = [ team_member_permissions = [
"create_key", "create_key",
@ -91,7 +98,9 @@ The following arguments are supported:
* `models` - (Optional) List of model names that this team can access. * `models` - (Optional) List of model names that this team can access.
* `metadata` - (Optional) A map of metadata key-value pairs associated with the team. * `metadata` - (Optional) A map of string metadata key-value pairs associated with the team. `tags` and `soft_budget_alerting_emails` are stored by the proxy under metadata but are managed through their own attributes below, not this map.
* `tags` - (Optional) List of tags applied to the team, used for [spend tracking](https://docs.litellm.ai/docs/proxy/enterprise#tracking-spend-for-custom-tags) and [tag-based routing](https://docs.litellm.ai/docs/proxy/tag_routing).
* `blocked` - (Optional) Whether the team is blocked from making requests. Default is `false`. * `blocked` - (Optional) Whether the team is blocked from making requests. Default is `false`.
@ -101,6 +110,10 @@ The following arguments are supported:
* `max_budget` - (Optional) Maximum budget allocated to the team. * `max_budget` - (Optional) Maximum budget allocated to the team.
* `soft_budget` - (Optional) Spend threshold at which the proxy sends a soft budget alert without blocking requests.
* `soft_budget_alerting_emails` - (Optional) List of email addresses notified when the team's spend crosses `soft_budget`.
* `budget_duration` - (Optional) Duration for the budget cycle. Valid values are: * `budget_duration` - (Optional) Duration for the budget cycle. Valid values are:
* `daily` * `daily`
* `weekly` * `weekly`

View file

@ -53,6 +53,11 @@ func ResourceLiteLLMTeam() *schema.Resource {
Type: schema.TypeFloat, Type: schema.TypeFloat,
Optional: true, Optional: true,
}, },
"soft_budget": {
Type: schema.TypeFloat,
Optional: true,
Description: "Spend threshold that triggers a soft budget alert without blocking requests",
},
"budget_duration": { "budget_duration": {
Type: schema.TypeString, Type: schema.TypeString,
Optional: true, Optional: true,
@ -72,6 +77,18 @@ func ResourceLiteLLMTeam() *schema.Resource {
Elem: &schema.Schema{Type: schema.TypeString}, Elem: &schema.Schema{Type: schema.TypeString},
Description: "List of permissions granted to team members", Description: "List of permissions granted to team members",
}, },
"tags": {
Type: schema.TypeList,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "Tags for spend tracking and tag-based routing",
},
"soft_budget_alerting_emails": {
Type: schema.TypeList,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "Email addresses alerted when the team crosses soft_budget",
},
}, },
} }
} }
@ -117,21 +134,20 @@ func resourceLiteLLMTeamRead(d *schema.ResourceData, m interface{}) error {
return nil return nil
} }
var teamResp TeamResponse var infoResp TeamInfoResponse
if err := json.NewDecoder(resp.Body).Decode(&teamResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&infoResp); err != nil {
return fmt.Errorf("error decoding team info response: %w", err) return fmt.Errorf("error decoding team info response: %w", err)
} }
teamResp := infoResp.TeamInfo
// Update the state with values from the response or fall back to the data passed in during creation // Update the state with values from the response or fall back to the data passed in during creation
d.Set("team_alias", GetStringValue(teamResp.TeamAlias, d.Get("team_alias").(string))) d.Set("team_alias", GetStringValue(teamResp.TeamAlias, d.Get("team_alias").(string)))
d.Set("organization_id", GetStringValue(teamResp.OrganizationID, d.Get("organization_id").(string))) d.Set("organization_id", GetStringValue(teamResp.OrganizationID, d.Get("organization_id").(string)))
// Handle metadata separately as it's a map metadata, tags, alertEmails := splitTeamMetadata(teamResp.Metadata)
if teamResp.Metadata != nil { d.Set("metadata", metadata)
d.Set("metadata", teamResp.Metadata) d.Set("tags", tags)
} else { d.Set("soft_budget_alerting_emails", alertEmails)
d.Set("metadata", d.Get("metadata"))
}
if teamResp.TPMLimit != nil { if teamResp.TPMLimit != nil {
d.Set("tpm_limit", *teamResp.TPMLimit) d.Set("tpm_limit", *teamResp.TPMLimit)
@ -142,6 +158,7 @@ func resourceLiteLLMTeamRead(d *schema.ResourceData, m interface{}) error {
if teamResp.MaxBudget != nil { if teamResp.MaxBudget != nil {
d.Set("max_budget", *teamResp.MaxBudget) d.Set("max_budget", *teamResp.MaxBudget)
} }
d.Set("soft_budget", teamResp.SoftBudget)
d.Set("budget_duration", GetStringValue(teamResp.BudgetDuration, d.Get("budget_duration").(string))) d.Set("budget_duration", GetStringValue(teamResp.BudgetDuration, d.Get("budget_duration").(string)))
// Handle models separately as it's a list // Handle models separately as it's a list
@ -240,15 +257,77 @@ func buildTeamData(d *schema.ResourceData, teamID string) map[string]interface{}
"team_alias": d.Get("team_alias").(string), "team_alias": d.Get("team_alias").(string),
} }
for _, key := range []string{"organization_id", "metadata", "tpm_limit", "rpm_limit", "max_budget", "budget_duration", "models", "blocked", "team_member_permissions"} { for _, key := range []string{"organization_id", "tpm_limit", "rpm_limit", "max_budget", "budget_duration", "models", "blocked", "team_member_permissions"} {
if v, ok := d.GetOk(key); ok { if v, ok := d.GetOk(key); ok {
teamData[key] = v teamData[key] = v
} }
} }
if v, ok := d.GetOk("soft_budget"); ok {
teamData["soft_budget"] = v
} else if d.HasChange("soft_budget") {
teamData["soft_budget"] = nil
}
if v, ok := d.GetOk("tags"); ok || d.HasChange("tags") {
teamData["tags"] = v
}
if metadata := buildTeamMetadata(d); metadata != nil {
teamData["metadata"] = metadata
}
return teamData return teamData
} }
// /team/update replaces metadata wholesale, so the full map must go out whenever either half changed.
func buildTeamMetadata(d *schema.ResourceData) map[string]interface{} {
metadata := map[string]interface{}{}
for k, v := range d.Get("metadata").(map[string]interface{}) {
metadata[k] = v
}
if v, ok := d.GetOk("soft_budget_alerting_emails"); ok {
metadata["soft_budget_alerting_emails"] = v
}
if len(metadata) == 0 && !d.HasChange("metadata") && !d.HasChange("soft_budget_alerting_emails") {
return nil
}
return metadata
}
func splitTeamMetadata(raw map[string]interface{}) (map[string]string, []string, []string) {
metadata := map[string]string{}
var tags, alertEmails []string
for k, v := range raw {
switch k {
case "tags":
tags = toStringSlice(v)
case "soft_budget_alerting_emails":
alertEmails = toStringSlice(v)
case "team_member_budget_id":
default:
if s, ok := v.(string); ok {
metadata[k] = s
}
}
}
return metadata, tags, alertEmails
}
func toStringSlice(v interface{}) []string {
items, ok := v.([]interface{})
if !ok {
return nil
}
out := make([]string, 0, len(items))
for _, item := range items {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}
func handleResponse(resp *http.Response, action string) error { func handleResponse(resp *http.Response, action string) error {
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)

View file

@ -0,0 +1,184 @@
package litellm
import (
"context"
"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 newTeamTestServer(t *testing.T, captured *map[string]interface{}, infoBody string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case endpointTeamNew, endpointTeamUpdate:
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, captured)
w.Write([]byte(`{}`))
case endpointTeamInfo:
w.Write([]byte(infoBody))
case endpointTeamPermissionsList:
w.Write([]byte(`{"team_id":"team-1","team_member_permissions":[],"all_available_permissions":[]}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}
const teamInfoWithSoftBudget = `{
"team_id": "team-1",
"team_info": {
"team_id": "team-1",
"team_alias": "insights",
"max_budget": 750.0,
"soft_budget": 600.0,
"models": ["claude-haiku-4-5"],
"metadata": {
"department": "customer-insights",
"tags": ["team:customer-insights", "environment:production"],
"soft_budget_alerting_emails": ["finops@example.com"],
"team_member_budget_id": "budget-1"
}
},
"keys": [],
"team_memberships": []
}`
func TestTeamCreateSendsSoftBudgetTagsAndAlertEmails(t *testing.T) {
var captured map[string]interface{}
srv := newTeamTestServer(t, &captured, teamInfoWithSoftBudget)
defer srv.Close()
d := schema.TestResourceDataRaw(t, ResourceLiteLLMTeam().Schema, map[string]interface{}{
"team_alias": "insights",
"max_budget": 750.0,
"soft_budget": 600.0,
"tags": []interface{}{"team:customer-insights", "environment:production"},
"soft_budget_alerting_emails": []interface{}{"finops@example.com"},
"metadata": map[string]interface{}{"department": "customer-insights"},
})
if err := resourceLiteLLMTeamCreate(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("create failed: %v", err)
}
if got := captured["soft_budget"]; got != 600.0 {
t.Fatalf("payload soft_budget = %v, want 600", got)
}
wantTags := []interface{}{"team:customer-insights", "environment:production"}
if got := captured["tags"]; !reflect.DeepEqual(got, wantTags) {
t.Fatalf("payload tags = %v, want %v", got, wantTags)
}
wantMetadata := map[string]interface{}{
"department": "customer-insights",
"soft_budget_alerting_emails": []interface{}{"finops@example.com"},
}
if got := captured["metadata"]; !reflect.DeepEqual(got, wantMetadata) {
t.Fatalf("payload metadata = %v, want %v", got, wantMetadata)
}
}
func TestTeamReadMapsTeamInfoEnvelope(t *testing.T) {
var captured map[string]interface{}
srv := newTeamTestServer(t, &captured, teamInfoWithSoftBudget)
defer srv.Close()
d := schema.TestResourceDataRaw(t, ResourceLiteLLMTeam().Schema, map[string]interface{}{})
d.SetId("team-1")
if err := resourceLiteLLMTeamRead(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("read failed: %v", err)
}
if got := d.Get("team_alias"); got != "insights" {
t.Fatalf("team_alias = %v, want insights", got)
}
if got := d.Get("soft_budget"); got != 600.0 {
t.Fatalf("soft_budget = %v, want 600", got)
}
if got := d.Get("max_budget"); got != 750.0 {
t.Fatalf("max_budget = %v, want 750", got)
}
wantTags := []interface{}{"team:customer-insights", "environment:production"}
if got := d.Get("tags"); !reflect.DeepEqual(got, wantTags) {
t.Fatalf("tags = %v, want %v", got, wantTags)
}
wantEmails := []interface{}{"finops@example.com"}
if got := d.Get("soft_budget_alerting_emails"); !reflect.DeepEqual(got, wantEmails) {
t.Fatalf("soft_budget_alerting_emails = %v, want %v", got, wantEmails)
}
wantMetadata := map[string]interface{}{"department": "customer-insights"}
if got := d.Get("metadata"); !reflect.DeepEqual(got, wantMetadata) {
t.Fatalf("metadata = %v, want %v (server-managed team_member_budget_id dropped)", got, wantMetadata)
}
}
func TestTeamUpdateClearsRemovedTagsAndSoftBudget(t *testing.T) {
var captured map[string]interface{}
srv := newTeamTestServer(t, &captured, `{"team_id":"team-1","team_info":{"team_id":"team-1","team_alias":"insights"},"keys":[],"team_memberships":[]}`)
defer srv.Close()
res := ResourceLiteLLMTeam()
priorData := schema.TestResourceDataRaw(t, res.Schema, map[string]interface{}{
"team_alias": "insights",
"soft_budget": 600.0,
"tags": []interface{}{"team:to-be-removed"},
"soft_budget_alerting_emails": []interface{}{"ops@example.com"},
"metadata": map[string]interface{}{"department": "eng"},
})
priorData.SetId("team-1")
prior := priorData.State()
config := terraform.NewResourceConfigRaw(map[string]interface{}{
"team_alias": "insights",
"metadata": map[string]interface{}{"department": "eng"},
})
diff, err := res.Diff(context.Background(), prior, config, nil)
if err != nil {
t.Fatalf("diff failed: %v", err)
}
d, err := schema.InternalMap(res.Schema).Data(prior, diff)
if err != nil {
t.Fatalf("data failed: %v", err)
}
if err := resourceLiteLLMTeamUpdate(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("update failed: %v", err)
}
if got, ok := captured["soft_budget"]; !ok || got != nil {
t.Fatalf("payload soft_budget = %v (present=%v), want explicit null", got, ok)
}
if got := captured["tags"]; !reflect.DeepEqual(got, []interface{}{}) {
t.Fatalf("payload tags = %v, want []", got)
}
if got := captured["metadata"]; !reflect.DeepEqual(got, map[string]interface{}{"department": "eng"}) {
t.Fatalf("payload metadata = %v, want department only", got)
}
}
func TestTeamReadClearsSoftBudgetWhenProxyReturnsNull(t *testing.T) {
var captured map[string]interface{}
srv := newTeamTestServer(t, &captured, `{"team_id":"team-1","team_info":{"team_id":"team-1","team_alias":"insights","soft_budget":null},"keys":[],"team_memberships":[]}`)
defer srv.Close()
d := schema.TestResourceDataRaw(t, ResourceLiteLLMTeam().Schema, map[string]interface{}{
"team_alias": "insights",
"soft_budget": 600.0,
})
d.SetId("team-1")
if err := resourceLiteLLMTeamRead(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("read failed: %v", err)
}
if got := d.Get("soft_budget"); got != 0.0 {
t.Fatalf("soft_budget = %v, want cleared after the proxy returned null", got)
}
}

View file

@ -33,6 +33,11 @@ type ModelRequest struct {
Additional map[string]interface{} `json:"additional"` Additional map[string]interface{} `json:"additional"`
} }
type TeamInfoResponse struct {
TeamID string `json:"team_id"`
TeamInfo TeamResponse `json:"team_info"`
}
// TeamResponse represents a response from the API containing team information. // TeamResponse represents a response from the API containing team information.
type TeamResponse struct { type TeamResponse struct {
TeamID string `json:"team_id,omitempty"` TeamID string `json:"team_id,omitempty"`
@ -42,6 +47,7 @@ type TeamResponse struct {
TPMLimit *int `json:"tpm_limit,omitempty"` TPMLimit *int `json:"tpm_limit,omitempty"`
RPMLimit *int `json:"rpm_limit,omitempty"` RPMLimit *int `json:"rpm_limit,omitempty"`
MaxBudget *float64 `json:"max_budget,omitempty"` MaxBudget *float64 `json:"max_budget,omitempty"`
SoftBudget *float64 `json:"soft_budget,omitempty"`
BudgetDuration string `json:"budget_duration,omitempty"` BudgetDuration string `json:"budget_duration,omitempty"`
Models []string `json:"models"` Models []string `json:"models"`
Blocked bool `json:"blocked,omitempty"` Blocked bool `json:"blocked,omitempty"`