This commit is contained in:
Marcus Greer 2026-10-30 06:21:39 -07:00 • committed by GitHub
commit 8b0e30829a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 389 additions and 24 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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