Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions terraform/provider/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ longer signal it.

## [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

- **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
Expand Down
15 changes: 14 additions & 1 deletion terraform/provider/docs/resources/team.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,18 @@ resource "litellm_team" "advanced_team" {

# Budget and rate limiting
max_budget = 1000.0
soft_budget = 800.0
budget_duration = "1mo"
tpm_limit = 500000
rpm_limit = 5000
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 = [
"create_key",
Expand Down Expand Up @@ -91,7 +98,9 @@ The following arguments are supported:

* `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`.

Expand All @@ -101,6 +110,10 @@ The following arguments are supported:

* `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:
* `daily`
* `weekly`
Expand Down
97 changes: 88 additions & 9 deletions terraform/provider/litellm/resource_team.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ func ResourceLiteLLMTeam() *schema.Resource {
Type: schema.TypeFloat,
Optional: true,
},
"soft_budget": {
Type: schema.TypeFloat,
Optional: true,
Description: "Spend threshold that triggers a soft budget alert without blocking requests",
},
"budget_duration": {
Type: schema.TypeString,
Optional: true,
Expand All @@ -72,6 +77,18 @@ func ResourceLiteLLMTeam() *schema.Resource {
Elem: &schema.Schema{Type: schema.TypeString},
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",
},
},
}
}
Expand Down Expand Up @@ -117,21 +134,20 @@ func resourceLiteLLMTeamRead(d *schema.ResourceData, m interface{}) error {
return nil
}

var teamResp TeamResponse
if err := json.NewDecoder(resp.Body).Decode(&teamResp); err != nil {
var infoResp TeamInfoResponse
if err := json.NewDecoder(resp.Body).Decode(&infoResp); err != nil {
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
d.Set("team_alias", GetStringValue(teamResp.TeamAlias, d.Get("team_alias").(string)))
d.Set("organization_id", GetStringValue(teamResp.OrganizationID, d.Get("organization_id").(string)))

// Handle metadata separately as it's a map
if teamResp.Metadata != nil {
d.Set("metadata", teamResp.Metadata)
} else {
d.Set("metadata", d.Get("metadata"))
}
metadata, tags, alertEmails := splitTeamMetadata(teamResp.Metadata)
d.Set("metadata", metadata)
d.Set("tags", tags)
d.Set("soft_budget_alerting_emails", alertEmails)

if teamResp.TPMLimit != nil {
d.Set("tpm_limit", *teamResp.TPMLimit)
Expand All @@ -142,6 +158,7 @@ func resourceLiteLLMTeamRead(d *schema.ResourceData, m interface{}) error {
if teamResp.MaxBudget != nil {
d.Set("max_budget", *teamResp.MaxBudget)
}
d.Set("soft_budget", teamResp.SoftBudget)
d.Set("budget_duration", GetStringValue(teamResp.BudgetDuration, d.Get("budget_duration").(string)))

// Handle models separately as it's a list
Expand Down Expand Up @@ -240,15 +257,77 @@ func buildTeamData(d *schema.ResourceData, teamID string) map[string]interface{}
"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 {
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
}

// /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 {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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:
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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 {
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
Expand Down
184 changes: 184 additions & 0 deletions terraform/provider/litellm/resource_team_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading