feat(terraform): add display_name to litellm_model resource and model data sources (#42987)

* feat(terraform): add display_name to litellm_model resource and model data sources

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(terraform): persist display_name on update and read /model/info data envelope

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore(terraform): drop PATCH /model/{model_id}/update from endpoint audit allowlist

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(terraform): surface external display_name removal as drift on refresh

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci(terraform): rerun after uv download timeout

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-24 17:06:30 -05:00 • committed by GitHub
parent b72a030501
commit e3f087315d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 333 additions and 24 deletions

View file

@ -16,6 +16,7 @@ longer signal it.
### Added
- **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
- **key**: Computed `server_metadata` attribute on `litellm_key` exposing every metadata entry the proxy stores, so metadata created outside Terraform is visible in state and drift on it shows on refresh, while `metadata` keeps tracking only the declared entries and updates keep preserving undeclared ones
- **team_member_add**: `tpm_limit`, `rpm_limit`, `budget_duration`, and `allowed_models` attributes on `litellm_team_member_add`, applied to every member of the resource; `budget_duration` and `allowed_models` ride on `/team/member_add`, while the limits are sent through `/team/member_update`, which is where the proxy accepts them
- **team**: Optional `team_id` argument on `litellm_team`, so teams can be created with a stable, human-readable ID instead of a provider-generated UUID; changing it forces replacement
@ -48,6 +49,7 @@ longer signal it.
### Fixed
- **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
- **key**: An update that changes `team_id` and fails because the key was already cascade-deleted along with its previous team now recovers by recreating the key under the new team, instead of aborting the apply. The key's absence is confirmed against the proxy first, so an unrelated failure still errors out, and a `team_id` change between two teams that both still exist stays a plain in-place update
- **credential**: create now reports a `credential_name` collision as a clear error naming the `terraform import` command that adopts the existing credential, instead of surfacing the proxy's raw 500 with a Prisma `Unique constraint failed` message. New `adopt_existing` argument (default `false`) opts into taking the existing credential over during create, which makes `apply` idempotent again once state loses track of a credential that still exists on the proxy. Requires a proxy that answers 409 on the collision; older proxies are still detected by their 500 message
- **credential**: credential names and `model_id` are now percent-encoded in request URLs, so a name containing `/`, `?`, `#` or spaces reaches the proxy intact instead of being cut at the first reserved character and read, updated or deleted as a different credential

View file

@ -43,6 +43,7 @@ In addition to all arguments above, the following attributes are exported:
* `tier` - Model tier (`free` or `paid`).
* `mode` - Model mode, e.g. `chat` or `embedding`.
* `team_id` - Team the deployment is scoped to, if any.
* `display_name` - Human-readable name returned by `/v1/models`, if configured.
* `db_model` - Whether the deployment is stored in the database (as opposed to config).
## Security Note

View file

@ -41,4 +41,5 @@ In addition to all arguments above, the following attributes are exported:
* `tier` - Model tier (`free` or `paid`).
* `mode` - Model mode, e.g. `chat` or `embedding`.
* `team_id` - Team the deployment is scoped to, if any.
* `display_name` - Human-readable name returned by `/v1/models`, if configured.
* `db_model` - Whether the deployment is stored in the database.

View file

@ -126,6 +126,8 @@ The following arguments are supported:
* `team_id` - (Optional) string. Associate the model with a specific team.
* `display_name` - (Optional) string. Human-readable name stored in `model_info.display_name` and returned as `display_name` by `/v1/models`, so clients such as Claude Code and Claude Desktop show it in their model picker instead of `model_name`. When unset, clients fall back to `model_name`.
* `mode` - (Optional) string. The intended use of the model. Valid values are:
* `completion`
* `embedding`

View file

@ -23,14 +23,15 @@ type modelInfoParams struct {
}
type modelInfoMeta struct {
ID string `json:"id"`
DBModel bool `json:"db_model"`
BaseModel string `json:"base_model"`
Tier string `json:"tier"`
Mode string `json:"mode"`
TeamID string `json:"team_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID string `json:"id"`
DBModel bool `json:"db_model"`
BaseModel string `json:"base_model"`
Tier string `json:"tier"`
Mode string `json:"mode"`
TeamID string `json:"team_id"`
DisplayName string `json:"display_name"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type modelInfoEntry struct {
@ -112,6 +113,10 @@ func dataSourceLiteLLMModel() *schema.Resource {
Type: schema.TypeString,
Computed: true,
},
"display_name": {
Type: schema.TypeString,
Computed: true,
},
"db_model": {
Type: schema.TypeBool,
Computed: true,
@ -161,6 +166,7 @@ func dataSourceLiteLLMModelRead(d *schema.ResourceData, m interface{}) error {
d.Set("tier", entry.ModelInfo.Tier)
d.Set("mode", entry.ModelInfo.Mode)
d.Set("team_id", entry.ModelInfo.TeamID)
d.Set("display_name", entry.ModelInfo.DisplayName)
d.Set("db_model", entry.ModelInfo.DBModel)
log.Printf("[INFO] Successfully read model with ID: %s", modelID)
@ -197,6 +203,7 @@ func dataSourceLiteLLMModels() *schema.Resource {
"tier": {Type: schema.TypeString, Computed: true},
"mode": {Type: schema.TypeString, Computed: true},
"team_id": {Type: schema.TypeString, Computed: true},
"display_name": {Type: schema.TypeString, Computed: true},
"db_model": {Type: schema.TypeBool, Computed: true},
},
},
@ -247,6 +254,7 @@ func dataSourceLiteLLMModelsRead(d *schema.ResourceData, m interface{}) error {
"tier": entry.ModelInfo.Tier,
"mode": entry.ModelInfo.Mode,
"team_id": entry.ModelInfo.TeamID,
"display_name": entry.ModelInfo.DisplayName,
"db_model": entry.ModelInfo.DBModel,
})
}

View file

@ -35,7 +35,8 @@ func TestDataSourceModelReadSingleObject(t *testing.T) {
"base_model": "gpt-4o",
"tier": "paid",
"mode": "chat",
"team_id": "team-1"
"team_id": "team-1",
"display_name": "GPT-4o"
}
}
}`))
@ -66,6 +67,7 @@ func TestDataSourceModelReadSingleObject(t *testing.T) {
"tier": "paid",
"mode": "chat",
"team_id": "team-1",
"display_name": "GPT-4o",
"db_model": true,
}
for attr, want := range checks {
@ -115,7 +117,7 @@ func TestDataSourceModelsRead(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"data": [
{"model_name": "a", "litellm_params": {"model": "openai/a", "custom_llm_provider": "openai"}, "model_info": {"id": "id-1", "db_model": true}},
{"model_name": "a", "litellm_params": {"model": "openai/a", "custom_llm_provider": "openai"}, "model_info": {"id": "id-1", "db_model": true, "display_name": "Model A"}},
{"model_name": "b", "litellm_params": {"model": "anthropic/b", "custom_llm_provider": "anthropic"}, "model_info": {"id": "id-2"}}
]
}`))
@ -143,7 +145,11 @@ func TestDataSourceModelsRead(t *testing.T) {
t.Fatalf("expected 2 models, got %d", len(models))
}
first := models[0].(map[string]interface{})
if first["model_name"] != "a" || first["custom_llm_provider"] != "openai" || first["db_model"] != true {
if first["model_name"] != "a" || first["custom_llm_provider"] != "openai" || first["db_model"] != true || first["display_name"] != "Model A" {
t.Errorf("unexpected first model: %v", first)
}
second := models[1].(map[string]interface{})
if second["display_name"] != "" {
t.Errorf("expected empty display_name for model without one, got %v", second["display_name"])
}
}

View file

@ -93,6 +93,11 @@ func resourceLiteLLMModel() *schema.Resource {
Type: schema.TypeString,
Optional: true,
},
"display_name": {
Type: schema.TypeString,
Optional: true,
Description: "Human-readable name returned as display_name by /v1/models, shown in client model pickers instead of model_name",
},
"mode": {
Type: schema.TypeString,
Optional: true,

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"log"
"net/url"
"strconv"
"strings"
"time"
@ -53,6 +54,7 @@ func retryModelRead(d *schema.ResourceData, m interface{}, maxRetries int) error
const (
endpointModelNew = "/model/new"
endpointModelUpdate = "/model/update"
endpointModelPatch = "/model/%s/update"
endpointModelInfo = "/model/info"
endpointModelDelete = "/model/delete"
)
@ -246,12 +248,13 @@ func createOrUpdateModel(d *schema.ResourceData, m interface{}, isUpdate bool) e
ModelName: d.Get("model_name").(string),
LiteLLMParams: litellmParams,
ModelInfo: ModelInfo{
ID: modelID,
DBModel: true,
BaseModel: pricingBaseModel,
Tier: d.Get("tier").(string),
Mode: d.Get("mode").(string),
TeamID: d.Get("team_id").(string),
ID: modelID,
DBModel: true,
BaseModel: pricingBaseModel,
Tier: d.Get("tier").(string),
Mode: d.Get("mode").(string),
TeamID: d.Get("team_id").(string),
DisplayName: d.Get("display_name").(string),
},
Additional: make(map[string]interface{}),
}
@ -275,6 +278,12 @@ 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)
}
}
d.SetId(modelID)
log.Printf("[INFO] Model created with ID %s. Starting retry mechanism to read the model...", modelID)
@ -282,6 +291,19 @@ func createOrUpdateModel(d *schema.ResourceData, m interface{}, isUpdate bool) e
return retryModelRead(d, m, 5)
}
// /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},
})
if err != nil {
return err
}
defer resp.Body.Close()
_, err = handleAPIResponse(resp, nil, client)
return err
}
func resourceLiteLLMModelCreate(d *schema.ResourceData, m interface{}) error {
return createOrUpdateModel(d, m, false)
}
@ -327,6 +349,7 @@ func resourceLiteLLMModelRead(d *schema.ResourceData, m interface{}) error {
d.Set("tier", GetStringValue(modelResp.ModelInfo.Tier, d.Get("tier").(string)))
d.Set("mode", GetStringValue(modelResp.ModelInfo.Mode, d.Get("mode").(string)))
d.Set("team_id", GetStringValue(modelResp.ModelInfo.TeamID, d.Get("team_id").(string)))
d.Set("display_name", modelResp.ModelInfo.DisplayName)
// Preserve credential name from state since it might not be returned by API
d.Set("litellm_credential_name", d.Get("litellm_credential_name").(string))

View file

@ -0,0 +1,244 @@
package litellm
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
)
func modelInfoBody(displayName string) string {
modelInfo := map[string]interface{}{
"id": "model-123",
"db_model": true,
"base_model": "claude-sonnet-4-5",
"tier": "free",
"mode": "chat",
}
if displayName != "" {
modelInfo["display_name"] = displayName
}
body, _ := json.Marshal(map[string]interface{}{
"model_name": "sonnet-4-5-anthropic",
"litellm_params": map[string]interface{}{"model": "anthropic/claude-sonnet-4-5", "custom_llm_provider": "anthropic"},
"model_info": modelInfo,
})
return string(body)
}
func modelInfoDataEnvelope(displayName string) string {
return `{"data": [` + modelInfoBody(displayName) + `]}`
}
func TestResourceLiteLLMModelCreateSendsDisplayName(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("Claude Sonnet 4.5")))
case "/model/info":
w.Write([]byte(modelInfoBody("Claude Sonnet 4.5")))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
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",
"model_api_key": "sk-ant-test",
"mode": "chat",
"display_name": "Claude Sonnet 4.5",
})
if err := resourceLiteLLMModelCreate(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("create failed: %v", err)
}
modelInfo, ok := createPayload["model_info"].(map[string]interface{})
if !ok {
t.Fatalf("expected model_info object in create payload, got %v", createPayload["model_info"])
}
if modelInfo["display_name"] != "Claude Sonnet 4.5" {
t.Errorf("expected model_info.display_name 'Claude Sonnet 4.5', got %v", modelInfo["display_name"])
}
if got := d.Get("display_name").(string); got != "Claude Sonnet 4.5" {
t.Errorf("expected state display_name 'Claude Sonnet 4.5', got %q", got)
}
}
func TestResourceLiteLLMModelCreateOmitsUnsetDisplayName(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()
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",
"model_api_key": "sk-ant-test",
})
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{})
if _, present := modelInfo["display_name"]; present {
t.Errorf("expected display_name to be omitted from model_info when unset, got %v", modelInfo["display_name"])
}
if got := d.Get("display_name").(string); got != "" {
t.Errorf("expected empty state display_name, got %q", got)
}
}
func TestResourceLiteLLMModelReadDisplayName(t *testing.T) {
cases := map[string]struct {
serverBody string
want string
}{
"server value wins inside data envelope": {serverBody: modelInfoDataEnvelope("Renamed In Admin UI"), want: "Renamed In Admin UI"},
"server value wins unwrapped": {serverBody: modelInfoBody("Renamed In Admin UI"), want: "Renamed In Admin UI"},
"external removal clears state": {serverBody: modelInfoDataEnvelope(""), want: ""},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/model/info" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Write([]byte(tc.serverBody))
}))
defer srv.Close()
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",
"display_name": "Claude Sonnet 4.5",
})
d.SetId("model-123")
if err := resourceLiteLLMModelRead(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("read failed: %v", err)
}
if got := d.Get("display_name").(string); got != tc.want {
t.Errorf("expected display_name %q, got %q", tc.want, got)
}
})
}
}
func updateResourceData(t *testing.T, oldDisplayName, newDisplayName string) *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",
}
if oldDisplayName != "" {
attrs["display_name"] = oldDisplayName
}
state := &terraform.InstanceState{ID: "model-123", Attributes: attrs}
diff, err := res.Diff(context.Background(), state, &terraform.ResourceConfig{Config: map[string]interface{}{
"model_name": "sonnet-4-5-anthropic",
"custom_llm_provider": "anthropic",
"base_model": "claude-sonnet-4-5",
"display_name": newDisplayName,
}}, 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 TestResourceLiteLLMModelUpdatePatchesDisplayName(t *testing.T) {
cases := map[string]struct {
newName string
}{
"changed name is patched": {newName: "Claude Sonnet 4.5 v2"},
"cleared name is patched": {newName: ""},
}
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("Claude Sonnet 4.5")))
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(tc.newName)))
case r.URL.Path == "/model/info":
w.Write([]byte(modelInfoDataEnvelope(tc.newName)))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
d := updateResourceData(t, "Claude Sonnet 4.5", tc.newName)
if err := resourceLiteLLMModelUpdate(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("update failed: %v", err)
}
if patchPath != "/model/model-123/update" {
t.Fatalf("expected PATCH /model/model-123/update, got %q", patchPath)
}
modelInfo := patchPayload["model_info"].(map[string]interface{})
if modelInfo["display_name"] != tc.newName {
t.Errorf("expected patched display_name %q, got %v", tc.newName, modelInfo["display_name"])
}
if got := d.Get("display_name").(string); got != tc.newName {
t.Errorf("expected state display_name %q, got %q", tc.newName, got)
}
})
}
}
func TestResourceLiteLLMModelUpdateSkipsPatchWhenDisplayNameUnchanged(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPatch {
t.Errorf("unexpected PATCH %s", r.URL.Path)
}
w.Write([]byte(modelInfoDataEnvelope("Claude Sonnet 4.5")))
}))
defer srv.Close()
d := updateResourceData(t, "Claude Sonnet 4.5", "Claude Sonnet 4.5")
if err := resourceLiteLLMModelUpdate(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("update failed: %v", err)
}
}

View file

@ -25,6 +25,16 @@ 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"`
@ -108,12 +118,13 @@ type LiteLLMParams struct {
// ModelInfo represents information about a model.
type ModelInfo struct {
ID string `json:"id"`
DBModel bool `json:"db_model"`
BaseModel string `json:"base_model"`
Tier string `json:"tier"`
Mode string `json:"mode"`
TeamID string `json:"team_id,omitempty"`
ID string `json:"id"`
DBModel bool `json:"db_model"`
BaseModel string `json:"base_model"`
Tier string `json:"tier"`
Mode string `json:"mode"`
TeamID string `json:"team_id,omitempty"`
DisplayName string `json:"display_name,omitempty"`
}
// Key represents a LiteLLM API key.

View file

@ -55,6 +55,13 @@ func handleAPIResponse(resp *http.Response, reqBody interface{}, client *Client)
resp.Status, client.redactSensitiveData(string(bodyBytes)), client.redactSensitiveData(string(reqBodyBytes)))
}
var envelope struct {
Data []json.RawMessage `json:"data"`
}
if err := json.Unmarshal(bodyBytes, &envelope); err == nil && len(envelope.Data) > 0 {
bodyBytes = envelope.Data[0]
}
var modelResp ModelResponse
if err := json.Unmarshal(bodyBytes, &modelResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %v", err)

View file

@ -97,7 +97,6 @@ GET /guardrails/{guardrail_id}
GET /prompts/{prompt_id}
GET /prompts/{prompt_id}/versions
PATCH /guardrails/{guardrail_id}
PATCH /model/{model_id}/update
PATCH /prompts/{prompt_id}
PATCH /team/{team_id}
POST /team/model/add