diff --git a/terraform/provider/CHANGELOG.md b/terraform/provider/CHANGELOG.md index 597e0c6d4d8..62e4ab03cc2 100644 --- a/terraform/provider/CHANGELOG.md +++ b/terraform/provider/CHANGELOG.md @@ -51,6 +51,8 @@ longer signal it. ### Fixed +- **unified_access_group**: `description` now converges. Update sends `description` on every apply (as `""` when unset) instead of only when non-empty, so removing it or setting it to `""` clears it on the proxy instead of planning the same no-op update forever; Read sets `description` to `""` when the proxy returns `null`, so a description cleared out-of-band is detected as drift (#47007) +- **unified_access_group**: Delete now treats a `404` from `DELETE /v1/unified_access_group/{id}` as success, so a `destroy -refresh=false`, or a group deleted between refresh and apply, no longer fails; same handling `litellm_fallback`'s Delete already uses (#47007) - **model**: `additional_drop_params` in `litellm_model.additional_litellm_params` is now sent to the proxy as the deployment's `additional_drop_params` list, the same list a `model_list` entry takes. Before, the provider never sent the list and instead deleted every key it named from `litellm_params`, so a `store` or `vertex_location` listed there was lost and requests still carried the parameters (#38349). The value must be a JSON list of parameter names such as `jsonencode(["store"])`, which `terraform plan` now checks, and `null` entries are skipped as before. When the configuration sets the list, refresh reads the stored list back from the proxy, so a list changed outside Terraform shows up as drift. On a model an older provider created, the proxy has no stored list, so the first plan after upgrading shows the list and every listed key the configuration sets as changes, since that apply writes them. A write-only secret such as `model_api_key_wo` is never shown in a plan - **user**: `litellm_user` updates no longer send `blocked`, which `/user/update` writes into the user table where no such column exists, so every update failed with a 400 (`Could not find field at upsertOneLiteLLM_UserTable.create.blocked`). Create still sends `blocked`, including `false`, so a `default_internal_user_params.blocked` on the proxy cannot block the auto-created key. Changing `blocked` after creation has no effect, since the proxy has no user-level block; use `litellm_key_block` instead - **model**: `litellm_model` refresh now reads the `{"data": [...]}` envelope `/model/info` returns, so `model_info` fields changed outside Terraform show up as drift instead of silently keeping the previous state diff --git a/terraform/provider/litellm/resource_unified_access_group.go b/terraform/provider/litellm/resource_unified_access_group.go index 0b2a67ebf23..7e015fe4f49 100644 --- a/terraform/provider/litellm/resource_unified_access_group.go +++ b/terraform/provider/litellm/resource_unified_access_group.go @@ -110,9 +110,7 @@ func resourceLiteLLMUnifiedAccessGroup() *schema.Resource { func buildUnifiedAccessGroupData(d *schema.ResourceData) map[string]interface{} { data := map[string]interface{}{ "access_group_name": d.Get("access_group_name").(string), - } - if v, ok := d.GetOk("description"); ok { - data["description"] = v + "description": d.Get("description").(string), } for _, key := range unifiedAccessGroupListFields { data[key] = d.Get(key) @@ -125,6 +123,8 @@ func setUnifiedAccessGroupFields(d *schema.ResourceData, group unifiedAccessGrou d.Set("access_group_name", group.AccessGroupName) if group.Description != nil { d.Set("description", *group.Description) + } else { + d.Set("description", "") } d.Set("access_model_names", group.AccessModelNames) d.Set("access_mcp_server_ids", group.AccessMCPServerIDs) @@ -235,9 +235,11 @@ func resourceLiteLLMUnifiedAccessGroupDelete(d *schema.ResourceData, m interface } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("error deleting unified access group: %s - %s", resp.Status, string(body)) + if resp.StatusCode != http.StatusNotFound { + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("error deleting unified access group: %s - %s", resp.Status, string(body)) + } } log.Printf("[INFO] Successfully deleted unified access group with ID: %s", d.Id()) diff --git a/terraform/provider/litellm/resource_unified_access_group_test.go b/terraform/provider/litellm/resource_unified_access_group_test.go index 39ff2d24f74..c71e7e0e684 100644 --- a/terraform/provider/litellm/resource_unified_access_group_test.go +++ b/terraform/provider/litellm/resource_unified_access_group_test.go @@ -207,3 +207,84 @@ func TestUnifiedAccessGroupDelete(t *testing.T) { t.Fatalf("expected ID to be cleared after delete, got %q", d.Id()) } } + +func TestUnifiedAccessGroupUpdateClearsDescription(t *testing.T) { + var updatePayload map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "PUT": + if err := json.NewDecoder(r.Body).Decode(&updatePayload); err != nil { + t.Errorf("failed to decode update payload: %v", err) + } + w.Write(unifiedAccessGroupJSON("uag-123")) + case "GET": + w.Write(unifiedAccessGroupJSON("uag-123")) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := unifiedAccessGroupTestData(t, map[string]interface{}{ + "access_group_name": "prod-group", + }) + d.SetId("uag-123") + + if err := resourceLiteLLMUnifiedAccessGroupUpdate(d, client); err != nil { + t.Fatalf("update failed: %v", err) + } + + if _, ok := updatePayload["description"]; !ok { + t.Fatalf("expected description to be sent so it can be cleared, payload: %v", updatePayload) + } + if updatePayload["description"] != "" { + t.Fatalf("expected description '' in payload, got %v", updatePayload["description"]) + } +} + +func TestUnifiedAccessGroupReadClearsDescription(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := json.Marshal(unifiedAccessGroupResponse{ + AccessGroupID: "uag-123", + AccessGroupName: "prod-group", + Description: nil, + }) + w.Write(body) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := unifiedAccessGroupTestData(t, map[string]interface{}{ + "description": "before", + }) + d.SetId("uag-123") + + if err := resourceLiteLLMUnifiedAccessGroupRead(d, client); err != nil { + t.Fatalf("read failed: %v", err) + } + + if d.Get("description").(string) != "" { + t.Fatalf("expected description cleared to '' when proxy returns null, got %q", d.Get("description").(string)) + } +} + +func TestUnifiedAccessGroupDeleteNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"detail":"Access group 'uag-gone' not found"}`)) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + d := unifiedAccessGroupTestData(t, map[string]interface{}{}) + d.SetId("uag-gone") + + if err := resourceLiteLLMUnifiedAccessGroupDelete(d, client); err != nil { + t.Fatalf("expected nil error on 404 delete, got: %v", err) + } + if d.Id() != "" { + t.Fatalf("expected ID to be cleared after 404 delete, got %q", d.Id()) + } +}