From 150465a7ae961a266de3984cdeffe9f114ae4917 Mon Sep 17 00:00:00 2001 From: Priyansh Nandwana Date: Sat, 17 Oct 2026 12:11:18 +0530 Subject: [PATCH 1/2] fix(terraform): read litellm_tag model_max_budget back and keep the proxy budget_id Refresh now sets model_max_budget from /tag/info and compares it as JSON, so out-of-band per-model budget changes show as drift. budget_id is Optional + Computed, so a tag created with inline budget fields stops planning budget_id -> null on every run. Fixes #47010 --- terraform/provider/CHANGELOG.md | 1 + terraform/provider/docs/resources/tag.md | 2 +- terraform/provider/litellm/resource_tag.go | 28 ++++--- .../provider/litellm/resource_tag_test.go | 73 +++++++++++++++++++ 4 files changed, 92 insertions(+), 12 deletions(-) diff --git a/terraform/provider/CHANGELOG.md b/terraform/provider/CHANGELOG.md index 597e0c6d4d8..5d13b176724 100644 --- a/terraform/provider/CHANGELOG.md +++ b/terraform/provider/CHANGELOG.md @@ -67,6 +67,7 @@ longer signal it. - **mcp_server, model, key, organization_member**: Requests now accept any 2xx response instead of requiring exactly HTTP 200; the proxy legitimately returns 201 from `POST /v1/mcp/server` (and other create endpoints), so `litellm_mcp_server` create previously succeeded on the proxy but failed in the provider, leaving the resource out of state and risking a duplicate server on retry - **unified_access_group**: create now accepts any 2xx response instead of requiring exactly HTTP 200; `POST /v1/unified_access_group` legitimately returns 201, so creation previously succeeded on the proxy but failed in the provider, leaving the group out of state and forcing a `terraform import` to recover on the next apply's 409. Same fix and shape as the one already applied to `mcp_server`/`model`/`key`/`organization_member`; `handleResponse` (shared by several other resources) was the one status-check helper that fix didn't reach. The legacy `litellm_access_group` resource calls the unrelated `/access_group/new` endpoint, which already returns 200, so it was never affected - **mcp_server**: A plan right after a clean apply of a minimal litellm_mcp_server no longer shows changes. alias is now computed so the proxy's default (the server name) is kept, spec_version keeps the configured value when the proxy returns none, and an mcp_info block the proxy fills with fields the provider does not model is no longer written to state +- **tag**: Refresh now reads `model_max_budget` back from `/tag/info`, so a per-model budget changed outside Terraform shows up as drift and an imported tag no longer plans to set it again, and equivalent JSON (key order, `25` vs `25.0`) plans no change, matching `litellm_budget` and `litellm_key` (#47010). `budget_id` is now computed as well as optional, so a tag created with inline budget fields keeps the id of the budget the proxy made instead of planning `budget_id -> null` on every run ### Changed diff --git a/terraform/provider/docs/resources/tag.md b/terraform/provider/docs/resources/tag.md index 98b274bf9e3..b54170fdb20 100644 --- a/terraform/provider/docs/resources/tag.md +++ b/terraform/provider/docs/resources/tag.md @@ -25,7 +25,7 @@ The following arguments are supported: * `name` - (Required, Forces new resource) Unique name of the tag. Also used as the resource ID * `description` - (Optional) Description of the tag * `models` - (Optional) List of model IDs this tag applies to -* `budget_id` - (Optional) Existing budget ID to associate with this tag. If omitted and budget fields are set, the proxy creates a budget +* `budget_id` - (Optional) Existing budget ID to associate with this tag. If omitted and budget fields are set, the proxy creates a budget and its ID is stored here * `max_budget` - (Optional) Max budget in USD for this tag * `soft_budget` - (Optional) Soft budget in USD for this tag * `max_parallel_requests` - (Optional) Max concurrent requests allowed for this tag diff --git a/terraform/provider/litellm/resource_tag.go b/terraform/provider/litellm/resource_tag.go index dd1505541cb..6b1ab7ec122 100644 --- a/terraform/provider/litellm/resource_tag.go +++ b/terraform/provider/litellm/resource_tag.go @@ -19,13 +19,14 @@ const ( ) type tagBudgetTable struct { - BudgetID string `json:"budget_id"` - MaxBudget *float64 `json:"max_budget"` - SoftBudget *float64 `json:"soft_budget"` - MaxParallelRequests *int `json:"max_parallel_requests"` - TPMLimit *int `json:"tpm_limit"` - RPMLimit *int `json:"rpm_limit"` - BudgetDuration string `json:"budget_duration"` + BudgetID string `json:"budget_id"` + MaxBudget *float64 `json:"max_budget"` + SoftBudget *float64 `json:"soft_budget"` + MaxParallelRequests *int `json:"max_parallel_requests"` + TPMLimit *int `json:"tpm_limit"` + RPMLimit *int `json:"rpm_limit"` + BudgetDuration string `json:"budget_duration"` + ModelMaxBudget interface{} `json:"model_max_budget"` } type tagInfoEntry struct { @@ -68,7 +69,8 @@ func resourceLiteLLMTag() *schema.Resource { "budget_id": { Type: schema.TypeString, Optional: true, - Description: "Existing budget ID to associate with this tag.", + Computed: true, + Description: "Existing budget ID to associate with this tag. When unset, holds the ID of the budget the proxy creates for inline budget fields.", }, "max_budget": { Type: schema.TypeFloat, @@ -101,9 +103,10 @@ func resourceLiteLLMTag() *schema.Resource { Description: "Duration for budget reset (e.g. '1h', '1d', '30d').", }, "model_max_budget": { - Type: schema.TypeString, - Optional: true, - Description: "JSON object string with per-model budget configuration.", + Type: schema.TypeString, + Optional: true, + DiffSuppressFunc: budgetSuppressEquivalentJSON, + Description: "JSON object string with per-model budget configuration.", }, }, } @@ -233,6 +236,9 @@ func resourceLiteLLMTagRead(d *schema.ResourceData, m interface{}) error { d.Set("rpm_limit", *bt.RPMLimit) } d.Set("budget_duration", GetStringValue(bt.BudgetDuration, d.Get("budget_duration").(string))) + if encoded, ok := budgetModelMaxBudgetString(bt.ModelMaxBudget); ok { + d.Set("model_max_budget", encoded) + } } log.Printf("[INFO] Successfully read tag with name: %s", d.Id()) diff --git a/terraform/provider/litellm/resource_tag_test.go b/terraform/provider/litellm/resource_tag_test.go index f6bcc6d74ab..4ddb1a446ea 100644 --- a/terraform/provider/litellm/resource_tag_test.go +++ b/terraform/provider/litellm/resource_tag_test.go @@ -1,6 +1,7 @@ package litellm import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -8,6 +9,7 @@ import ( "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) func tagInfoBody(name string) string { @@ -140,6 +142,77 @@ func TestResourceLiteLLMTagRead(t *testing.T) { } } +const tagInfoWithModelMaxBudget = `{"prod": { + "name": "prod", + "litellm_budget_table": { + "budget_id": "bud-created-by-proxy", + "model_max_budget": {"gpt-4": {"max_budget": 25.0, "budget_duration": "1d"}} + } +}}` + +// readTagFromProxy returns the state a Read leaves behind for a tag configured with config. +func readTagFromProxy(t *testing.T, config map[string]interface{}) *schema.ResourceData { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(tagInfoWithModelMaxBudget)) + })) + defer srv.Close() + + d := schema.TestResourceDataRaw(t, resourceLiteLLMTag().Schema, config) + d.SetId("prod") + if err := resourceLiteLLMTagRead(d, NewClient(srv.URL, "test-key", true)); err != nil { + t.Fatalf("read failed: %v", err) + } + return d +} + +func planAttributes(t *testing.T, prior *schema.ResourceData, config map[string]interface{}) map[string]*terraform.ResourceAttrDiff { + t.Helper() + diff, err := resourceLiteLLMTag().Diff(context.Background(), prior.State(), terraform.NewResourceConfigRaw(config), nil) + if err != nil { + t.Fatalf("diff failed: %v", err) + } + if diff == nil { + return nil + } + return diff.Attributes +} + +func TestResourceLiteLLMTagRead_PicksUpModelMaxBudgetChangedOnTheProxy(t *testing.T) { + d := readTagFromProxy(t, map[string]interface{}{ + "name": "prod", + "model_max_budget": `{"gpt-4": {"max_budget": 10, "budget_duration": "1d"}}`, + }) + + if !budgetSuppressEquivalentJSON("", d.Get("model_max_budget").(string), `{"gpt-4": {"max_budget": 25, "budget_duration": "1d"}}`, nil) { + t.Errorf("expected the proxy's per-model budget in state, got %s", d.Get("model_max_budget")) + } +} + +func TestResourceLiteLLMTag_PlanAfterInlineBudgetCreateKeepsTheProxyBudgetID(t *testing.T) { + config := map[string]interface{}{ + "name": "prod", + "model_max_budget": `{"gpt-4": {"max_budget": 25, "budget_duration": "1d"}}`, + } + prior := readTagFromProxy(t, config) + + if attrs := planAttributes(t, prior, config); attrs["budget_id"] != nil { + t.Errorf("expected no budget_id change when the config leaves it unset, got %+v", attrs["budget_id"]) + } +} + +func TestResourceLiteLLMTag_EquivalentModelMaxBudgetJSONPlansNoChange(t *testing.T) { + prior := readTagFromProxy(t, map[string]interface{}{"name": "prod"}) + + attrs := planAttributes(t, prior, map[string]interface{}{ + "name": "prod", + "model_max_budget": `{"gpt-4": {"max_budget": 25.0, "budget_duration": "1d"}}`, + }) + if attrs["model_max_budget"] != nil { + t.Errorf("expected key order and number formatting to plan no change, got %+v", attrs["model_max_budget"]) + } +} + func TestResourceLiteLLMTagRead_404ClearsID(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) From 686a3b72f3f7ae8618607c75357a6049abfb5868 Mon Sep 17 00:00:00 2001 From: Priyansh Nandwana Date: Sat, 17 Oct 2026 12:47:30 +0530 Subject: [PATCH 2/2] fix(terraform): show a litellm_tag model_max_budget cleared on the proxy as drift When /tag/info returns a budget table without a per-model budget, refresh now clears model_max_budget in state instead of keeping the old value, so the next plan sets it again --- terraform/provider/CHANGELOG.md | 2 +- terraform/provider/litellm/resource_tag.go | 5 ++--- .../provider/litellm/resource_tag_test.go | 21 +++++++++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/terraform/provider/CHANGELOG.md b/terraform/provider/CHANGELOG.md index 5d13b176724..851a0650c89 100644 --- a/terraform/provider/CHANGELOG.md +++ b/terraform/provider/CHANGELOG.md @@ -67,7 +67,7 @@ longer signal it. - **mcp_server, model, key, organization_member**: Requests now accept any 2xx response instead of requiring exactly HTTP 200; the proxy legitimately returns 201 from `POST /v1/mcp/server` (and other create endpoints), so `litellm_mcp_server` create previously succeeded on the proxy but failed in the provider, leaving the resource out of state and risking a duplicate server on retry - **unified_access_group**: create now accepts any 2xx response instead of requiring exactly HTTP 200; `POST /v1/unified_access_group` legitimately returns 201, so creation previously succeeded on the proxy but failed in the provider, leaving the group out of state and forcing a `terraform import` to recover on the next apply's 409. Same fix and shape as the one already applied to `mcp_server`/`model`/`key`/`organization_member`; `handleResponse` (shared by several other resources) was the one status-check helper that fix didn't reach. The legacy `litellm_access_group` resource calls the unrelated `/access_group/new` endpoint, which already returns 200, so it was never affected - **mcp_server**: A plan right after a clean apply of a minimal litellm_mcp_server no longer shows changes. alias is now computed so the proxy's default (the server name) is kept, spec_version keeps the configured value when the proxy returns none, and an mcp_info block the proxy fills with fields the provider does not model is no longer written to state -- **tag**: Refresh now reads `model_max_budget` back from `/tag/info`, so a per-model budget changed outside Terraform shows up as drift and an imported tag no longer plans to set it again, and equivalent JSON (key order, `25` vs `25.0`) plans no change, matching `litellm_budget` and `litellm_key` (#47010). `budget_id` is now computed as well as optional, so a tag created with inline budget fields keeps the id of the budget the proxy made instead of planning `budget_id -> null` on every run +- **tag**: Refresh now reads `model_max_budget` back from `/tag/info`, so a per-model budget changed or cleared outside Terraform shows up as drift and an imported tag no longer plans to set it again, and equivalent JSON (key order, `25` vs `25.0`) plans no change, matching `litellm_budget` and `litellm_key` (#47010). `budget_id` is now computed as well as optional, so a tag created with inline budget fields keeps the id of the budget the proxy made instead of planning `budget_id -> null` on every run ### Changed diff --git a/terraform/provider/litellm/resource_tag.go b/terraform/provider/litellm/resource_tag.go index 6b1ab7ec122..393fdf76f12 100644 --- a/terraform/provider/litellm/resource_tag.go +++ b/terraform/provider/litellm/resource_tag.go @@ -236,9 +236,8 @@ func resourceLiteLLMTagRead(d *schema.ResourceData, m interface{}) error { d.Set("rpm_limit", *bt.RPMLimit) } d.Set("budget_duration", GetStringValue(bt.BudgetDuration, d.Get("budget_duration").(string))) - if encoded, ok := budgetModelMaxBudgetString(bt.ModelMaxBudget); ok { - d.Set("model_max_budget", encoded) - } + modelMaxBudget, _ := budgetModelMaxBudgetString(bt.ModelMaxBudget) + d.Set("model_max_budget", modelMaxBudget) } log.Printf("[INFO] Successfully read tag with name: %s", d.Id()) diff --git a/terraform/provider/litellm/resource_tag_test.go b/terraform/provider/litellm/resource_tag_test.go index 4ddb1a446ea..894511e7365 100644 --- a/terraform/provider/litellm/resource_tag_test.go +++ b/terraform/provider/litellm/resource_tag_test.go @@ -189,6 +189,27 @@ func TestResourceLiteLLMTagRead_PicksUpModelMaxBudgetChangedOnTheProxy(t *testin } } +func TestResourceLiteLLMTag_ModelMaxBudgetClearedOnTheProxyPlansItBack(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"prod": {"name": "prod", "litellm_budget_table": {"budget_id": "bud-1", "model_max_budget": null}}}`)) + })) + defer srv.Close() + config := map[string]interface{}{ + "name": "prod", + "model_max_budget": `{"gpt-4": {"max_budget": 10, "budget_duration": "1d"}}`, + } + prior := schema.TestResourceDataRaw(t, resourceLiteLLMTag().Schema, config) + prior.SetId("prod") + if err := resourceLiteLLMTagRead(prior, NewClient(srv.URL, "test-key", true)); err != nil { + t.Fatalf("read failed: %v", err) + } + + attrs := planAttributes(t, prior, config) + if attrs["model_max_budget"] == nil || attrs["model_max_budget"].Old != "" { + t.Errorf("expected the plan to set model_max_budget again after it was cleared on the proxy, got %+v", attrs["model_max_budget"]) + } +} + func TestResourceLiteLLMTag_PlanAfterInlineBudgetCreateKeepsTheProxyBudgetID(t *testing.T) { config := map[string]interface{}{ "name": "prod",