diff --git a/terraform/provider/CHANGELOG.md b/terraform/provider/CHANGELOG.md index 597e0c6d4d8..60c6ba96fdf 100644 --- a/terraform/provider/CHANGELOG.md +++ b/terraform/provider/CHANGELOG.md @@ -16,6 +16,7 @@ longer signal it. ### Added +- **model**: Optional `additional_model_info` map on `litellm_model`, merged into the `model_info` sent on create and converted like `additional_litellm_params` (so `jsonencode({...})` arrives as a nested object); changed entries are persisted in place through `/model/{id}/update`. Keys the provider or another argument owns (`id`, `db_model`, `base_model`, `tier`, `mode`, `team_id`, `display_name`) are rejected at plan time. Removing a key does not remove it from the proxy, which merges `model_info`, and the value is not read back, so out-of-band changes do not show as drift - **credential, model, mcp_server**: Write-only secret attributes so secrets never land in Terraform state (Terraform 1.11 or later). `litellm_credential` gains `credential_values_wo` (a JSON object, `jsonencode({...})`, since the SDK cannot mark a map write-only) with `credential_values_wo_version`, and `credential_values` becomes optional with exactly one of the two required. `litellm_model` gains `model_api_key_wo`, `aws_secret_access_key_wo` and `vertex_credentials_wo`, each with a `_wo_version` companion and each conflicting with its in-state twin. `litellm_mcp_server` gains `client_id`, `client_secret_wo` and `auth_value_wo` (with `_wo_version` companions), sent to the proxy as `credentials`, and `auth_type` now accepts every value the proxy does (`oauth2`, `api_key`, `bearer_token`, ...). Every `_wo` value is sent on each apply from the configuration and produces no diff, so bump its `_wo_version` to push a rotated secret. `litellm_vector_store.litellm_params` warns at plan time when it carries a secret-looking key such as `api_key` - **team**: Optional `team_member_model_max_budget` argument on `litellm_team`, a JSON string of per-model budgets applied to every team member and sent as `team_member_model_max_budget` on `/team/new` and `/team/update`; read back from `team_member_budget_table.model_max_budget` on `/team/info` so out-of-band changes show as drift, with `budget_limit`/`time_period` treated as equal to the `max_budget`/`budget_duration` spelling the proxy stores; removing it sends `{}`, which clears every per-model member budget; `tpm_limit`/`rpm_limit` are rejected at plan time because the proxy rejects them on team budgets. Needs a proxy that includes [#43887](https://github.com/BerriAI/litellm/pull/43887): v1.105.0 and v1.106.0-rc.1 accept the field but silently drop it, so every plan shows a diff - **model**: Optional `display_name` argument on `litellm_model`, sent as `model_info.display_name` and returned as `display_name` by `/v1/models`, so client model pickers show a readable name; changes are persisted through `/model/{id}/update` since `/model/update` ignores `model_info`; also exported by the `litellm_model` and `litellm_models` data sources diff --git a/terraform/provider/docs/resources/model.md b/terraform/provider/docs/resources/model.md index 85c495bf1ca..a6bf48a424d 100644 --- a/terraform/provider/docs/resources/model.md +++ b/terraform/provider/docs/resources/model.md @@ -213,6 +213,27 @@ The following arguments are supported: } ``` +* `additional_model_info` - (Optional) map(string). Extra entries merged into the `model_info` object sent to the LiteLLM API, for example custom metadata that clients read from `/model/info`. Values are converted the same way as `additional_litellm_params`, so `jsonencode({...})` arrives at the proxy as a nested JSON object. + * Keys that other arguments own or that the provider sets (`id`, `db_model`, `base_model`, `tier`, `mode`, `team_id`, `display_name`) are rejected at plan time. Use `base_model`, `pricing_base_model`, `tier`, `mode`, `team_id` or `display_name` instead. + * Changes are applied in place through `PATCH /model/{id}/update` and never replace the model. Only added or changed entries are sent. + * Limitation: removing a key from the map does not remove it from the proxy, because the proxy merges `model_info` on update. To stop a key taking effect, set it to an empty value your clients ignore. A change inside a JSON-object value does work, since the whole value is replaced. + * The provider does not read `additional_model_info` back from the proxy, since `/model/info` returns many computed `model_info` fields. State keeps the configured value, so changes made outside Terraform are not shown as drift. + + ```hcl + resource "litellm_model" "with_model_info" { + model_name = "custom-model" + custom_llm_provider = "openai" + model_api_key = var.openai_api_key + base_model = "gpt-4" + mode = "chat" + + additional_model_info = { + owner = "platform" + catalog = jsonencode({ tier = "gold", regions = ["us", "eu"] }) # nested object on the proxy + } + } + ``` + ### AWS-specific Configuration * `aws_access_key_id` - (Optional) string (Sensitive). AWS access key ID for AWS-based models. diff --git a/terraform/provider/litellm/resource_model.go b/terraform/provider/litellm/resource_model.go index e9582ad75fb..e7521954a8c 100644 --- a/terraform/provider/litellm/resource_model.go +++ b/terraform/provider/litellm/resource_model.go @@ -1,6 +1,8 @@ package litellm import ( + "fmt" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) @@ -241,6 +243,41 @@ func resourceLiteLLMModel() *schema.Resource { DiffSuppressFunc: suppressEquivalentDropParams, Description: "Additional parameters to pass to litellm_params beyond the standard ones", }, + "additional_model_info": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + ValidateFunc: validateAdditionalModelInfoKeys, + Description: "Additional entries merged into model_info. Values are converted like additional_litellm_params, so jsonencode() produces a nested object. Removing a key does not remove it from the proxy.", + }, }, } } + +var modelInfoKeyOwners = map[string]string{ + "id": "", + "db_model": "", + "base_model": "base_model or pricing_base_model", + "tier": "tier", + "mode": "mode", + "team_id": "team_id", + "display_name": "display_name", +} + +func validateAdditionalModelInfoKeys(value interface{}, attribute string) ([]string, []error) { + entries, _ := value.(map[string]interface{}) + var errs []error + for key := range entries { + owner, reserved := modelInfoKeyOwners[key] + switch { + case !reserved: + case owner == "": + errs = append(errs, fmt.Errorf("%s: key %q is set by the provider and cannot be overridden", attribute, key)) + default: + errs = append(errs, fmt.Errorf("%s: key %q is managed by the %s argument, set it there instead", attribute, key, owner)) + } + } + return nil, errs +} diff --git a/terraform/provider/litellm/resource_model_crud.go b/terraform/provider/litellm/resource_model_crud.go index 3db6f68760d..93331d43dcc 100644 --- a/terraform/provider/litellm/resource_model_crud.go +++ b/terraform/provider/litellm/resource_model_crud.go @@ -161,7 +161,15 @@ func refreshAdditionalDropParams(d *schema.ResourceData, stored interface{}) { d.Set("additional_litellm_params", refreshed) } -func coerceAdditionalParamValue(strValue string) interface{} { +func coerceStringMap(raw map[string]interface{}) map[string]interface{} { + coerced := make(map[string]interface{}, len(raw)) + for key, value := range raw { + coerced[key] = coerceStringValue(value.(string)) + } + return coerced +} + +func coerceStringValue(strValue string) interface{} { trimmedValue := strings.TrimSpace(strValue) if strings.HasPrefix(trimmedValue, "[") || strings.HasPrefix(trimmedValue, "{") { var parsedValue interface{} @@ -292,16 +300,16 @@ func createOrUpdateModel(d *schema.ResourceData, m interface{}, isUpdate bool) e } if additionalParams, ok := d.GetOk("additional_litellm_params"); ok { - for key, value := range additionalParams.(map[string]interface{}) { - if key != additionalDropParamsKey { - litellmParams[key] = coerceAdditionalParamValue(value.(string)) - continue - } - dropParams, err := parseAdditionalDropParams(value.(string)) + rawParams := maps.Clone(additionalParams.(map[string]interface{})) + rawDropParams, hasDropParams := rawParams[additionalDropParamsKey] + delete(rawParams, additionalDropParamsKey) + maps.Copy(litellmParams, coerceStringMap(rawParams)) + if hasDropParams { + dropParams, err := parseAdditionalDropParams(rawDropParams.(string)) if err != nil { return err } - litellmParams[key] = dropParams + litellmParams[additionalDropParamsKey] = dropParams } } @@ -321,6 +329,8 @@ func createOrUpdateModel(d *schema.ResourceData, m interface{}, isUpdate bool) e Mode: d.Get("mode").(string), TeamID: d.Get("team_id").(string), DisplayName: d.Get("display_name").(string), + + AdditionalInfo: coerceStringMap(d.Get("additional_model_info").(map[string]interface{})), }, Additional: make(map[string]interface{}), } @@ -344,9 +354,11 @@ func createOrUpdateModel(d *schema.ResourceData, m interface{}, isUpdate bool) e return fmt.Errorf("failed to %s model: %w", map[bool]string{true: "update", false: "create"}[isUpdate], err) } - if isUpdate && d.HasChange("display_name") { - if err := patchModelDisplayName(client, modelID, d.Get("display_name").(string)); err != nil { - return fmt.Errorf("failed to update model display_name: %w", err) + if isUpdate { + if patchFields := changedModelInfoFields(d); len(patchFields) > 0 { + if err := patchModelInfo(client, modelID, patchFields); err != nil { + return fmt.Errorf("failed to update model_info: %w", err) + } } } @@ -358,9 +370,11 @@ func createOrUpdateModel(d *schema.ResourceData, m interface{}, isUpdate bool) e } // /model/update only merges litellm_params, so model_info changes go through the PATCH endpoint. -func patchModelDisplayName(client *Client, modelID, displayName string) error { - resp, err := MakeRequest(client, "PATCH", fmt.Sprintf(endpointModelPatch, url.PathEscape(modelID)), ModelInfoPatch{ - ModelInfo: ModelInfoPatchFields{ID: modelID, DisplayName: displayName}, +func patchModelInfo(client *Client, modelID string, fields map[string]interface{}) error { + modelInfo := maps.Clone(fields) + modelInfo["id"] = modelID + resp, err := MakeRequest(client, "PATCH", fmt.Sprintf(endpointModelPatch, url.PathEscape(modelID)), map[string]interface{}{ + "model_info": modelInfo, }) if err != nil { return err @@ -370,6 +384,24 @@ func patchModelDisplayName(client *Client, modelID, displayName string) error { return err } +// display_name is included even when empty so it can be cleared. Keys removed from +// additional_model_info are not sent, since the proxy merges model_info rather than replacing it. +func changedModelInfoFields(d *schema.ResourceData) map[string]interface{} { + fields := map[string]interface{}{} + if d.HasChange("display_name") { + fields["display_name"] = d.Get("display_name").(string) + } + oldInfo, newInfo := d.GetChange("additional_model_info") + oldEntries := oldInfo.(map[string]interface{}) + for key, value := range newInfo.(map[string]interface{}) { + if oldValue, existed := oldEntries[key]; existed && oldValue == value { + continue + } + fields[key] = coerceStringValue(value.(string)) + } + return fields +} + func resourceLiteLLMModelCreate(d *schema.ResourceData, m interface{}) error { return createOrUpdateModel(d, m, false) } diff --git a/terraform/provider/litellm/resource_model_test.go b/terraform/provider/litellm/resource_model_test.go index 4c2d5d27442..3eecbb88812 100644 --- a/terraform/provider/litellm/resource_model_test.go +++ b/terraform/provider/litellm/resource_model_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "reflect" "slices" + "strings" "testing" "github.com/hashicorp/go-cty/cty" @@ -621,3 +622,261 @@ func TestResourceLiteLLMModelSchemaRejectsSecretInStateAndWriteOnly(t *testing.T }) } } + +func TestCoerceStringMap(t *testing.T) { + got := coerceStringMap(map[string]interface{}{ + "object": `{"nested": {"value": 42}}`, + "list": `["a", "b"]`, + "yes": "true", + "no": "false", + "integer": "16384", + "float": "0.75", + "text": "for testing", + "broken": "{not json", + }) + want := map[string]interface{}{ + "object": map[string]interface{}{"nested": map[string]interface{}{"value": float64(42)}}, + "list": []interface{}{"a", "b"}, + "yes": true, + "no": false, + "integer": 16384, + "float": 0.75, + "text": "for testing", + "broken": "{not json", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("coerceStringMap = %#v, want %#v", got, want) + } +} + +func TestResourceLiteLLMModelCreateSendsAdditionalModelInfo(t *testing.T) { + var createPayload map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/model/new": + if err := json.NewDecoder(r.Body).Decode(&createPayload); err != nil { + t.Errorf("failed to decode create payload: %v", err) + } + w.Write([]byte(modelInfoBody(""))) + case "/model/info": + w.Write([]byte(modelInfoBody(""))) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + nested, _ := json.Marshal(map[string]interface{}{"owner": "platform", "tags": []string{"a", "b"}, "limits": map[string]int{"rpm": 5}}) + d := schema.TestResourceDataRaw(t, resourceLiteLLMModel().Schema, map[string]interface{}{ + "model_name": "sonnet-4-5-anthropic", + "custom_llm_provider": "anthropic", + "base_model": "claude-sonnet-4-5", + "mode": "chat", + "display_name": "Claude Sonnet 4.5", + "additional_model_info": map[string]interface{}{ + "catalog": string(nested), + "supports_x": "true", + "max_items": "12", + "label": "plain", + }, + "additional_litellm_params": map[string]interface{}{"additional_drop_params": `["store"]`, "max_context": "16384"}, + }) + if err := resourceLiteLLMModelCreate(d, NewClient(srv.URL, "test-key", true)); err != nil { + t.Fatalf("create failed: %v", err) + } + + modelInfo := createPayload["model_info"].(map[string]interface{}) + want := map[string]interface{}{ + "catalog": map[string]interface{}{"owner": "platform", "tags": []interface{}{"a", "b"}, "limits": map[string]interface{}{"rpm": float64(5)}}, + "supports_x": true, + "max_items": float64(12), + "label": "plain", + "base_model": "claude-sonnet-4-5", + "mode": "chat", + "tier": "free", + "db_model": true, + "display_name": "Claude Sonnet 4.5", + } + for key, wantValue := range want { + if !reflect.DeepEqual(modelInfo[key], wantValue) { + t.Errorf("model_info[%q] = %#v, want %#v", key, modelInfo[key], wantValue) + } + } + if modelInfo["id"] == "" || modelInfo["id"] == nil { + t.Errorf("expected model_info.id to be set, got %v", modelInfo["id"]) + } + + litellmParams := createPayload["litellm_params"].(map[string]interface{}) + if !reflect.DeepEqual(litellmParams["additional_drop_params"], []interface{}{"store"}) { + t.Errorf("additional_drop_params = %#v", litellmParams["additional_drop_params"]) + } + if litellmParams["max_context"] != float64(16384) { + t.Errorf("max_context = %#v, want 16384", litellmParams["max_context"]) + } + if _, leaked := litellmParams["catalog"]; leaked { + t.Error("additional_model_info entry leaked into litellm_params") + } +} + +func TestModelInfoMarshalKeepsTypedFieldsOverAdditionalInfo(t *testing.T) { + body, err := json.Marshal(ModelInfo{ID: "real", Tier: "free", AdditionalInfo: map[string]interface{}{"id": "spoofed", "extra": 1}}) + if err != nil { + t.Fatal(err) + } + var got map[string]interface{} + if err := json.Unmarshal(body, &got); err != nil { + t.Fatal(err) + } + if got["id"] != "real" || got["extra"] != float64(1) { + t.Errorf("unexpected marshaled model_info: %s", body) + } +} + +func updateResourceDataWithModelInfo(t *testing.T, oldInfo, newInfo map[string]interface{}) *schema.ResourceData { + t.Helper() + res := resourceLiteLLMModel() + attrs := map[string]string{ + "model_name": "sonnet-4-5-anthropic", + "custom_llm_provider": "anthropic", + "base_model": "claude-sonnet-4-5", + } + for key, value := range oldInfo { + attrs["additional_model_info."+key] = value.(string) + } + state := &terraform.InstanceState{ID: "model-123", Attributes: attrs} + config := map[string]interface{}{ + "model_name": "sonnet-4-5-anthropic", + "custom_llm_provider": "anthropic", + "base_model": "claude-sonnet-4-5", + } + if newInfo != nil { + config["additional_model_info"] = newInfo + } + diff, err := res.Diff(context.Background(), state, &terraform.ResourceConfig{Config: config}, nil) + if err != nil { + t.Fatalf("diff failed: %v", err) + } + d, err := schema.InternalMap(res.Schema).Data(state, diff) + if err != nil { + t.Fatalf("data failed: %v", err) + } + return d +} + +func TestResourceLiteLLMModelUpdatePatchesChangedAdditionalModelInfo(t *testing.T) { + oldNested := `{"owner":"platform"}` + newNested := `{"owner":"data"}` + cases := map[string]struct { + oldInfo map[string]interface{} + newInfo map[string]interface{} + wantPatch map[string]interface{} + }{ + "changed nested value is sent whole": { + oldInfo: map[string]interface{}{"catalog": oldNested, "label": "a"}, + newInfo: map[string]interface{}{"catalog": newNested, "label": "a"}, + wantPatch: map[string]interface{}{"catalog": map[string]interface{}{"owner": "data"}}, + }, + "added key is sent with converted type": { + oldInfo: map[string]interface{}{"label": "a"}, + newInfo: map[string]interface{}{"label": "a", "supports_x": "true"}, + wantPatch: map[string]interface{}{"supports_x": true}, + }, + "removed key sends nothing": { + oldInfo: map[string]interface{}{"label": "a", "extra": "b"}, + newInfo: map[string]interface{}{"label": "a"}, + }, + "unchanged map sends nothing": { + oldInfo: map[string]interface{}{"catalog": oldNested}, + newInfo: map[string]interface{}{"catalog": oldNested}, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + var patchPayload map[string]interface{} + var patchPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/model/update": + w.Write([]byte(modelInfoBody(""))) + case r.Method == http.MethodPatch: + patchPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&patchPayload); err != nil { + t.Errorf("failed to decode patch payload: %v", err) + } + w.Write([]byte(modelInfoBody(""))) + case r.URL.Path == "/model/info": + w.Write([]byte(modelInfoDataEnvelope(""))) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + d := updateResourceDataWithModelInfo(t, tc.oldInfo, tc.newInfo) + if err := resourceLiteLLMModelUpdate(d, NewClient(srv.URL, "test-key", true)); err != nil { + t.Fatalf("update failed: %v", err) + } + if tc.wantPatch == nil { + if patchPayload != nil { + t.Errorf("expected no PATCH, got %v", patchPayload) + } + return + } + if patchPath != "/model/model-123/update" { + t.Fatalf("expected PATCH /model/model-123/update, got %q", patchPath) + } + wantInfo := map[string]interface{}{"id": "model-123"} + maps.Copy(wantInfo, tc.wantPatch) + if got := patchPayload["model_info"]; !reflect.DeepEqual(got, wantInfo) { + t.Errorf("patched model_info = %#v, want %#v", got, wantInfo) + } + }) + } +} + +func TestResourceLiteLLMModelRejectsReservedAdditionalModelInfoKeys(t *testing.T) { + cases := map[string]string{ + "id": "", + "db_model": "", + "base_model": "pricing_base_model", + "tier": "tier", + "mode": "mode", + "team_id": "team_id", + "display_name": "display_name", + } + for key, argument := range cases { + t.Run(key, func(t *testing.T) { + config := terraform.NewResourceConfigRaw(map[string]interface{}{ + "model_name": "sonnet-4-5-anthropic", + "custom_llm_provider": "anthropic", + "base_model": "claude-sonnet-4-5", + "additional_model_info": map[string]interface{}{key: "x", "allowed": "y"}, + }) + diags := resourceLiteLLMModel().Validate(config) + if !diags.HasError() { + t.Fatalf("expected validation error for key %q", key) + } + message := diags[0].Summary + diags[0].Detail + if !strings.Contains(message, `"`+key+`"`) || (argument != "" && !strings.Contains(message, argument)) { + t.Errorf("error should name key %q and argument %q, got %q", key, argument, message) + } + if strings.Contains(message, `"allowed"`) { + t.Errorf("error should not flag the allowed key: %q", message) + } + }) + } +} + +func TestResourceLiteLLMModelAcceptsUnreservedAdditionalModelInfoKeys(t *testing.T) { + config := terraform.NewResourceConfigRaw(map[string]interface{}{ + "model_name": "sonnet-4-5-anthropic", + "custom_llm_provider": "anthropic", + "base_model": "claude-sonnet-4-5", + "additional_model_info": map[string]interface{}{"catalog": `{"a":1}`, "supports_x": "true"}, + }) + if diags := resourceLiteLLMModel().Validate(config); diags.HasError() { + t.Errorf("unexpected validation error: %v", diags) + } +} diff --git a/terraform/provider/litellm/types.go b/terraform/provider/litellm/types.go index aff90cd64be..b19e19e242b 100644 --- a/terraform/provider/litellm/types.go +++ b/terraform/provider/litellm/types.go @@ -1,5 +1,10 @@ package litellm +import ( + "encoding/json" + "maps" +) + // ProviderConfig holds the configuration for the LiteLLM provider. type ProviderConfig struct { APIBase string @@ -25,16 +30,6 @@ type ModelResponse struct { Additional map[string]interface{} `json:"additional"` } -// ModelInfoPatch is the body for PATCH /model/{id}/update; display_name is sent even when empty so it can be cleared. -type ModelInfoPatch struct { - ModelInfo ModelInfoPatchFields `json:"model_info"` -} - -type ModelInfoPatchFields struct { - ID string `json:"id"` - DisplayName string `json:"display_name"` -} - // ModelRequest represents a request to create or update a model. type ModelRequest struct { ModelName string `json:"model_name"` @@ -132,6 +127,26 @@ type ModelInfo struct { Mode string `json:"mode"` TeamID string `json:"team_id,omitempty"` DisplayName string `json:"display_name,omitempty"` + + AdditionalInfo map[string]interface{} `json:"-"` +} + +type modelInfoFields ModelInfo + +// MarshalJSON merges AdditionalInfo into the object; the typed fields win on a key clash. +func (m ModelInfo) MarshalJSON() ([]byte, error) { + typed, err := json.Marshal(modelInfoFields(m)) + if err != nil { + return nil, err + } + if len(m.AdditionalInfo) == 0 { + return typed, nil + } + merged := maps.Clone(m.AdditionalInfo) + if err := json.Unmarshal(typed, &merged); err != nil { + return nil, err + } + return json.Marshal(merged) } // Key represents a LiteLLM API key.