From a2b2d4954c10c378c040dda89c4817704637ac6c Mon Sep 17 00:00:00 2001 From: Cosmin Frincu Date: Tue, 20 Oct 2026 11:27:50 +0200 Subject: [PATCH] fix(terraform): omit unchanged key passthrough routes --- terraform/provider/CHANGELOG.md | 1 + terraform/provider/litellm/resource_key.go | 3 + .../provider/litellm/resource_key_test.go | 88 +++++++++++++++++++ 3 files changed, 92 insertions(+) diff --git a/terraform/provider/CHANGELOG.md b/terraform/provider/CHANGELOG.md index 597e0c6d4d8..2999d865eb1 100644 --- a/terraform/provider/CHANGELOG.md +++ b/terraform/provider/CHANGELOG.md @@ -51,6 +51,7 @@ longer signal it. ### Fixed +- **key**: Updates no longer resend unchanged `allowed_passthrough_routes`, avoiding an Enterprise-only 403 on OSS proxies when passthrough metadata is managed outside Terraform - **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_key.go b/terraform/provider/litellm/resource_key.go index 2c751ae6a34..efd452b84a1 100644 --- a/terraform/provider/litellm/resource_key.go +++ b/terraform/provider/litellm/resource_key.go @@ -372,6 +372,9 @@ func resourceKeyUpdate(ctx context.Context, d *schema.ResourceData, m interface{ if allowedRoutesNotConfigured(d) { key.AllowedRoutes = nil } + if !d.HasChange("allowed_passthrough_routes") { + key.AllowedPassthroughRoutes = nil + } 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 3ad13b6c83b..19015639153 100644 --- a/terraform/provider/litellm/resource_key_test.go +++ b/terraform/provider/litellm/resource_key_test.go @@ -1,6 +1,7 @@ package litellm import ( + "bytes" "context" "encoding/json" "fmt" @@ -965,6 +966,93 @@ func applyKeyUpdate(t *testing.T, client *Client, stateAttrs map[string]string, return newState } +func TestKeyUpdateOmitsUnchangedPassthroughRoutes(t *testing.T) { + for _, field := range []string{"models", "allowed_routes"} { + t.Run(field, func(t *testing.T) { + const route = "/direct/openai/v1/batches" + proxy := &fakeKeyProxy{metadata: map[string]interface{}{ + "allowed_passthrough_routes": []string{route}, + "server_side": "preserved", + }} + handler := proxy.handler() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/key/update" { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + http.Error(w, "invalid test request", http.StatusBadRequest) + return + } + var payload map[string]interface{} + if err := json.Unmarshal(body, &payload); err != nil { + t.Error(err) + http.Error(w, "invalid test request", http.StatusBadRequest) + return + } + if _, present := payload["allowed_passthrough_routes"]; present { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"error":"Enterprise required: allowed_passthrough_routes"}`)) + return + } + r.Body = io.NopCloser(bytes.NewReader(body)) + } + handler(w, r) + })) + defer srv.Close() + + applyKeyUpdate(t, NewClient(srv.URL, "test-key", true), + map[string]string{ + "key_alias": "alias-1", + "allowed_passthrough_routes.#": "1", + "allowed_passthrough_routes.0": route, + "blocked": "true", + field + ".#": "1", + field + ".0": "previous", + }, + map[string]interface{}{ + "key_alias": "alias-1", + "allowed_passthrough_routes": []interface{}{route}, + "blocked": true, + field: []interface{}{"replacement"}, + }, + ) + if len(proxy.updates) != 1 { + t.Fatalf("expected one update, got %d", len(proxy.updates)) + } + if got := proxy.updates[0][field]; !reflect.DeepEqual(got, []interface{}{"replacement"}) { + t.Errorf("%s = %v, want [replacement]", field, got) + } + if proxy.updates[0]["blocked"] != true { + t.Error("update must preserve the operator's block") + } + if proxy.metadata["server_side"] != "preserved" || + !reflect.DeepEqual(proxy.metadata["allowed_passthrough_routes"], []string{route}) { + t.Errorf("server metadata changed: %v", proxy.metadata) + } + }) + } +} + +func TestKeyUpdateSendsChangedPassthroughRoutes(t *testing.T) { + proxy := &fakeKeyProxy{metadata: map[string]interface{}{}} + srv := httptest.NewServer(proxy.handler()) + defer srv.Close() + applyKeyUpdate(t, NewClient(srv.URL, "test-key", true), + map[string]string{ + "key_alias": "alias-1", + "allowed_passthrough_routes.#": "1", + "allowed_passthrough_routes.0": "/previous", + }, + map[string]interface{}{ + "key_alias": "alias-1", + "allowed_passthrough_routes": []interface{}{"/replacement"}, + }, + ) + if got := proxy.updates[0]["allowed_passthrough_routes"]; !reflect.DeepEqual(got, []interface{}{"/replacement"}) { + t.Errorf("changed passthrough routes = %v, want [/replacement]", got) + } +} + func TestKeyUpdateWithoutMetadataChangePreservesServerMetadata(t *testing.T) { proxy := &fakeKeyProxy{metadata: map[string]interface{}{"a": "1", "server_side": "x", "model_rpm_limit": map[string]interface{}{"gpt-4o-mini": float64(5)}}} srv := httptest.NewServer(proxy.handler())