feat(terraform): add tags to litellm_model

Add a tags list attribute to litellm_model, matching what /model/new
and /model/update already accept under litellm_params.tags for
tag-based routing (https://docs.litellm.ai/docs/proxy/tag_routing).

Follows the same GetOk-or-HasChange pattern used for litellm_team's
tags attribute (see resource_team.go) so an explicitly cleared list
is still sent to the API.
This commit is contained in:
JinSoo Jeong 2026-09-22 21:13:40 +09:00
parent 071cb49d32
commit 74fcd72001
6 changed files with 170 additions and 0 deletions

View file

@ -16,6 +16,7 @@ longer signal it.
### Added
- **model**: `tags` attribute on `litellm_model`, matching what `/model/new` and `/model/update` already accept under `litellm_params.tags`, for [tag-based routing](https://docs.litellm.ai/docs/proxy/tag_routing)
- **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
- `litellm_jwt_key_mapping` accepts `token_id` as an alternative to `key`, so a

View file

@ -33,6 +33,7 @@ resource "litellm_model" "advanced_gpt4" {
tier = "paid"
team_id = "team-123"
mode = "chat"
tags = ["team:platform"]
reasoning_effort = "medium"
thinking_enabled = true
thinking_budget_tokens = 1024
@ -122,6 +123,8 @@ The following arguments are supported:
* `litellm_credential_name` - (Optional) string. Name of a LiteLLM credential to use for this model.
* `tags` - (Optional) list(string). Tags applied to this model deployment, used for [tag-based routing](https://docs.litellm.ai/docs/proxy/tag_routing) so requests carrying a matching tag are routed to it.
* `tier` - (Optional) string. The usage tier for this model. Valid values are `"free"` or `"paid"`. Default: `"free"`.
* `team_id` - (Optional) string. Associate the model with a specific team.

View file

@ -175,6 +175,12 @@ func resourceLiteLLMModel() *schema.Resource {
Optional: true,
Description: "Name of the LiteLLM credential to use",
},
"tags": {
Type: schema.TypeList,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "Tags for tag-based routing (see https://docs.litellm.ai/docs/proxy/tag_routing)",
},
"additional_litellm_params": {
Type: schema.TypeMap,
Optional: true,

View file

@ -163,6 +163,9 @@ func createOrUpdateModel(d *schema.ResourceData, m interface{}, isUpdate bool) e
if thinking != nil {
litellmParams["thinking"] = thinking
}
if v, ok := d.GetOk("tags"); ok || d.HasChange("tags") {
litellmParams["tags"] = v
}
// Add additional parameters if provided
if additionalParams, ok := d.GetOk("additional_litellm_params"); ok {
@ -327,6 +330,11 @@ 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)))
if modelResp.LiteLLMParams.Tags != nil {
d.Set("tags", modelResp.LiteLLMParams.Tags)
} else {
d.Set("tags", d.Get("tags"))
}
// 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,151 @@
package litellm
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func newModelTestServer(t *testing.T, captured *map[string]interface{}, infoBody string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case endpointModelNew, endpointModelUpdate:
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, captured)
w.Write([]byte(infoBody))
case endpointModelInfo:
w.Write([]byte(infoBody))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}
func newModelResourceData(t *testing.T, raw map[string]interface{}) *schema.ResourceData {
t.Helper()
return schema.TestResourceDataRaw(t, resourceLiteLLMModel().Schema, raw)
}
func TestModelCreateSendsTags(t *testing.T) {
var captured map[string]interface{}
infoBody := `{
"model_name": "gpt-4.1",
"litellm_params": {
"model": "azure/gpt-4.1",
"custom_llm_provider": "azure",
"tags": ["advisor"]
},
"model_info": {
"id": "model-1",
"db_model": true,
"base_model": "gpt-4.1",
"tier": "free",
"mode": "chat"
}
}`
srv := newModelTestServer(t, &captured, infoBody)
defer srv.Close()
d := newModelResourceData(t, map[string]interface{}{
"model_name": "gpt-4.1",
"custom_llm_provider": "azure",
"base_model": "gpt-4.1",
"tags": []interface{}{"advisor"},
})
if err := resourceLiteLLMModelCreate(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("create failed: %v", err)
}
litellmParams, ok := captured["litellm_params"].(map[string]interface{})
if !ok {
t.Fatalf("payload litellm_params = %v, want a map", captured["litellm_params"])
}
wantTags := []interface{}{"advisor"}
if got := litellmParams["tags"]; !reflect.DeepEqual(got, wantTags) {
t.Fatalf("payload litellm_params.tags = %v, want %v", got, wantTags)
}
}
func TestModelReadPopulatesTagsFromResponse(t *testing.T) {
var captured map[string]interface{}
infoBody := `{
"model_name": "gpt-4.1",
"litellm_params": {
"model": "azure/gpt-4.1",
"custom_llm_provider": "azure",
"tags": ["advisor", "callbot"]
},
"model_info": {
"id": "model-1",
"db_model": true,
"base_model": "gpt-4.1",
"tier": "free",
"mode": "chat"
}
}`
srv := newModelTestServer(t, &captured, infoBody)
defer srv.Close()
d := newModelResourceData(t, map[string]interface{}{
"model_name": "gpt-4.1",
"custom_llm_provider": "azure",
"base_model": "gpt-4.1",
})
d.SetId("model-1")
if err := resourceLiteLLMModelRead(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("read failed: %v", err)
}
wantTags := []interface{}{"advisor", "callbot"}
if got := d.Get("tags"); !reflect.DeepEqual(got, wantTags) {
t.Fatalf("state tags = %v, want %v", got, wantTags)
}
}
func TestModelUpdateOmitsTagsWhenUnset(t *testing.T) {
var captured map[string]interface{}
infoBody := `{
"model_name": "gpt-4.1",
"litellm_params": {
"model": "azure/gpt-4.1",
"custom_llm_provider": "azure"
},
"model_info": {
"id": "model-1",
"db_model": true,
"base_model": "gpt-4.1",
"tier": "free",
"mode": "chat"
}
}`
srv := newModelTestServer(t, &captured, infoBody)
defer srv.Close()
d := newModelResourceData(t, map[string]interface{}{
"model_name": "gpt-4.1",
"custom_llm_provider": "azure",
"base_model": "gpt-4.1",
})
d.SetId("model-1")
if err := resourceLiteLLMModelUpdate(d, NewClient(srv.URL, "test-key", true)); err != nil {
t.Fatalf("update failed: %v", err)
}
litellmParams, ok := captured["litellm_params"].(map[string]interface{})
if !ok {
t.Fatalf("payload litellm_params = %v, want a map", captured["litellm_params"])
}
if _, ok := litellmParams["tags"]; ok {
t.Fatalf("payload litellm_params.tags = %v, want omitted when unset", litellmParams["tags"])
}
}

View file

@ -104,6 +104,7 @@ type LiteLLMParams struct {
VertexProject string `json:"vertex_project,omitempty"`
VertexLocation string `json:"vertex_location,omitempty"`
VertexCredentials string `json:"vertex_credentials,omitempty"`
Tags []string `json:"tags,omitempty"`
}
// ModelInfo represents information about a model.