This commit is contained in:
Priyansh Nandwana 2026-10-27 04:35:57 +08:00 • committed by GitHub
commit 43ab17331e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 112 additions and 12 deletions

View file

@ -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 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

View file

@ -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

View file

@ -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,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)))
modelMaxBudget, _ := budgetModelMaxBudgetString(bt.ModelMaxBudget)
d.Set("model_max_budget", modelMaxBudget)
}
log.Printf("[INFO] Successfully read tag with name: %s", d.Id())

View file

@ -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,98 @@ 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_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",
"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)