diff --git a/terraform/provider/litellm/client.go b/terraform/provider/litellm/client.go index 26e6a91a15c..b64c241c899 100644 --- a/terraform/provider/litellm/client.go +++ b/terraform/provider/litellm/client.go @@ -143,10 +143,10 @@ func (c *Client) UpdateKey(key *Key) (*Key, error) { updateData["model_tpm_limit"] = key.ModelTPMLimit } - // The proxy rejects an empty team_id with a 500 ("Team object not found"), - // so only send it when set. if key.TeamID != "" { updateData["team_id"] = key.TeamID + } else if key.clearTeamID { + updateData["team_id"] = nil } // The proxy rejects an empty-string budget_duration with a 400, so only diff --git a/terraform/provider/litellm/resource_key.go b/terraform/provider/litellm/resource_key.go index b471cab73e8..f1c38c33878 100644 --- a/terraform/provider/litellm/resource_key.go +++ b/terraform/provider/litellm/resource_key.go @@ -322,6 +322,7 @@ func resourceKeyUpdate(ctx context.Context, d *schema.ResourceData, m interface{ key := &Key{Key: d.Id()} mapResourceDataToKey(d, key) + key.clearTeamID = d.HasChange("team_id") && key.TeamID == "" if !d.HasChange("duration") { key.Duration = "" } diff --git a/terraform/provider/litellm/resource_key_test.go b/terraform/provider/litellm/resource_key_test.go index 8dd581a798e..431e7a2f784 100644 --- a/terraform/provider/litellm/resource_key_test.go +++ b/terraform/provider/litellm/resource_key_test.go @@ -321,8 +321,6 @@ func TestUpdateKeyOmitsEmptyBudgetDuration(t *testing.T) { } } -// The proxy 500s on team_id: "" with "Team object not found", so a teamless key -// must omit it from the update payload entirely. func TestUpdateKeyOmitsEmptyTeamID(t *testing.T) { var captured map[string]interface{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -349,6 +347,72 @@ func TestUpdateKeyOmitsEmptyTeamID(t *testing.T) { } } +func TestResourceKeyTeamChangesConverge(t *testing.T) { + for _, tc := range []struct { + name string + priorTeam string + configuredTeam interface{} + wantTeam string + wantPresent bool + wantNull bool + }{ + {name: "teamless"}, + {name: "remove", priorTeam: "team-1", wantPresent: true, wantNull: true}, + {name: "blank", priorTeam: "team-1", configuredTeam: "", wantPresent: true, wantNull: true}, + {name: "assign", configuredTeam: "team-1", wantTeam: "team-1", wantPresent: true}, + {name: "move", priorTeam: "team-1", configuredTeam: "team-2", wantTeam: "team-2", wantPresent: true}, + {name: "retain", priorTeam: "team-1", configuredTeam: "team-1", wantTeam: "team-1", wantPresent: true}, + } { + t.Run(tc.name, func(t *testing.T) { + storedTeam := tc.priorTeam + updates := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + if req.URL.Path == "/key/update" { + updates++ + var payload map[string]interface{} + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Error(err) + w.WriteHeader(http.StatusBadRequest) + return + } + value, present := payload["team_id"] + if present != tc.wantPresent || (present && (value == nil) != tc.wantNull) { + t.Errorf("team_id = %#v, present = %v; want present = %v, null = %v", value, present, tc.wantPresent, tc.wantNull) + } + if present { + storedTeam, _ = value.(string) + } + } + json.NewEncoder(w).Encode(map[string]interface{}{ + "key": "hash-1", + "info": map[string]interface{}{"team_id": storedTeam, "key_alias": "test"}, + }) + })) + defer srv.Close() + res := resourceKey() + priorData := newKeyResourceData(t, map[string]interface{}{"team_id": tc.priorTeam, "key_alias": "test", "max_budget": 10.0}) + priorData.SetId("hash-1") + config := terraform.NewResourceConfigRaw(map[string]interface{}{"team_id": tc.configuredTeam, "key_alias": "test", "max_budget": 25.0}) + diff, err := res.Diff(context.Background(), priorData.State(), config, nil) + if err != nil { + t.Fatal(err) + } + state, diags := res.Apply(context.Background(), priorData.State(), diff, NewClient(srv.URL, "test-key", true)) + if diags.HasError() { + t.Fatalf("apply failed: %v", diags) + } + if updates != 1 || storedTeam != tc.wantTeam || state.Attributes["team_id"] != tc.wantTeam { + t.Fatalf("updates = %d, stored team = %q, state team = %q; want %q", updates, storedTeam, state.Attributes["team_id"], tc.wantTeam) + } + nextDiff, err := res.Diff(context.Background(), state, config, nil) + if err != nil || (nextDiff != nil && !nextDiff.Empty()) { + t.Fatalf("subsequent plan not clean: diff = %v, error = %v", nextDiff, err) + } + }) + } +} + func TestResourceKeyUpdateFailureKeepsPriorState(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/terraform/provider/litellm/types.go b/terraform/provider/litellm/types.go index a8784b8a6a9..87115f39782 100644 --- a/terraform/provider/litellm/types.go +++ b/terraform/provider/litellm/types.go @@ -129,6 +129,7 @@ type ModelInfo struct { // Key represents a LiteLLM API key. type Key struct { + clearTeamID bool Key string `json:"key,omitempty"` TokenID string `json:"token_id,omitempty"` Models []string `json:"models"`