From fd254922e884de1efb795eb045f13e87ae08fea8 Mon Sep 17 00:00:00 2001 From: Erik Bogado Date: Mon, 7 Sep 2026 03:38:19 -0300 Subject: [PATCH] fix(terraform): persist model metadata --- terraform/provider/docs/resources/model.md | 3 + terraform/provider/litellm/resource_model.go | 84 ++- .../resource_model_cache_pricing_test.go | 80 ++- .../resource_model_catalog_metadata_test.go | 266 ++++++++++ .../provider/litellm/resource_model_crud.go | 212 +++++++- .../litellm/resource_model_s04_test.go | 494 ++++++++++++++++++ terraform/provider/litellm/types.go | 66 ++- terraform/provider/litellm/utils.go | 34 ++ .../endpointaudit/coverage_allowlist.txt | 5 +- 9 files changed, 1200 insertions(+), 44 deletions(-) create mode 100644 terraform/provider/litellm/resource_model_catalog_metadata_test.go create mode 100644 terraform/provider/litellm/resource_model_s04_test.go diff --git a/terraform/provider/docs/resources/model.md b/terraform/provider/docs/resources/model.md index 470ea176ecb..d4f8be0bd0a 100644 --- a/terraform/provider/docs/resources/model.md +++ b/terraform/provider/docs/resources/model.md @@ -237,6 +237,9 @@ terraform import litellm_model.gpt4 ``` Note: The model ID is generated when the model is created and is different from the `model_name`. +Import refresh uses LiteLLM's v2 model-info endpoint and requires exactly one +response whose `model_info.id` matches the imported ID. Missing, mismatched, or +ambiguous records are never accepted as model state. ## Security Note diff --git a/terraform/provider/litellm/resource_model.go b/terraform/provider/litellm/resource_model.go index 9c364f2fa0f..4c2bb22231a 100644 --- a/terraform/provider/litellm/resource_model.go +++ b/terraform/provider/litellm/resource_model.go @@ -92,6 +92,86 @@ func resourceLiteLLMModel() *schema.Resource { "team_id": { Type: schema.TypeString, Optional: true, + Computed: true, + }, + "max_input_tokens": { + Type: schema.TypeInt, + Optional: true, + ValidateFunc: validation.IntAtLeast(0), + }, + "max_output_tokens": { + Type: schema.TypeInt, + Optional: true, + ValidateFunc: validation.IntAtLeast(0), + }, + "input_modalities": { + Type: schema.TypeList, + Optional: true, + Computed: true, + Description: "Input modalities; empty or omitted preserves remote metadata because SDKv2 collapses empty optional lists", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "output_modalities": { + Type: schema.TypeList, + Optional: true, + Computed: true, + Description: "Output modalities; empty or omitted preserves remote metadata because SDKv2 collapses empty optional lists", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "supports_reasoning": { + Type: schema.TypeBool, + Optional: true, + }, + "supports_function_calling": { + Type: schema.TypeBool, + Optional: true, + }, + "supports_vision": { + Type: schema.TypeBool, + Optional: true, + Computed: true, + }, + "input_cost_per_character": { + Type: schema.TypeFloat, + Optional: true, + Computed: true, + ValidateFunc: validation.FloatAtLeast(0), + }, + "cache_read_input_cost_per_million_tokens": { + Type: schema.TypeFloat, + Optional: true, + Computed: true, + ValidateFunc: validation.FloatAtLeast(0), + }, + "default_voice": { + Type: schema.TypeString, + Optional: true, + Computed: true, + }, + "probe_language": { + Type: schema.TypeString, + Optional: true, + Computed: true, + }, + "probe_text": { + Type: schema.TypeString, + Optional: true, + Computed: true, + }, + "probe_skip": { + Type: schema.TypeBool, + Optional: true, + Computed: true, + }, + "max_tokens": { + Type: schema.TypeInt, + Optional: true, + Computed: true, + ValidateFunc: validation.IntAtLeast(0), }, "mode": { Type: schema.TypeString, @@ -111,10 +191,6 @@ func resourceLiteLLMModel() *schema.Resource { Type: schema.TypeFloat, Optional: true, }, - "cache_read_input_cost_per_million_tokens": { - Type: schema.TypeFloat, - Optional: true, - }, "output_cost_per_million_tokens": { Type: schema.TypeFloat, Optional: true, diff --git a/terraform/provider/litellm/resource_model_cache_pricing_test.go b/terraform/provider/litellm/resource_model_cache_pricing_test.go index ef33146707e..b60e5ce8632 100644 --- a/terraform/provider/litellm/resource_model_cache_pricing_test.go +++ b/terraform/provider/litellm/resource_model_cache_pricing_test.go @@ -13,18 +13,14 @@ func TestResourceLiteLLMModelCreateSendsCacheReadPricing(t *testing.T) { var payload map[string]interface{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - switch { - case r.Method == http.MethodPost && r.URL.Path == endpointModelNew: + if r.URL.Path == endpointModelNew { if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Fatalf("decode request: %v", err) } - json.NewEncoder(w).Encode(payload) - case r.Method == http.MethodGet && r.URL.Path == endpointModelInfo: - json.NewEncoder(w).Encode(payload) - default: - t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) - w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(payload) + return } + _ = json.NewEncoder(w).Encode(s04WrappedModelResponse(payload, r.URL.Query().Get("modelId"))) })) defer srv.Close() @@ -42,4 +38,72 @@ func TestResourceLiteLLMModelCreateSendsCacheReadPricing(t *testing.T) { if got := modelInfo["cache_read_input_token_cost"]; got != 0.0000006 { t.Fatalf("cache read cost: want 0.0000006, got %v", got) } + if got := d.Get("cache_read_input_cost_per_million_tokens"); got != 0.6 { + t.Fatalf("cache read state: want 0.6, got %v", got) + } +} + +func TestCacheReadPricingLifecycle(t *testing.T) { + for _, tc := range []struct { + name string + configured bool + price float64 + }{ + {"omitted", false, 0}, {"zero", true, 0}, {"positive", true, 0.6}, + } { + t.Run(tc.name, func(t *testing.T) { + var payload map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost || r.Method == http.MethodPatch { + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Error(err) + } + json.NewEncoder(w).Encode(payload) + return + } + json.NewEncoder(w).Encode(s04WrappedModelResponse(payload, r.URL.Query().Get("modelId"))) + })) + defer srv.Close() + raw := map[string]interface{}{"model_name": "cache-test", "custom_llm_provider": "openai", "base_model": "cache-test"} + if tc.configured { + raw["cache_read_input_cost_per_million_tokens"] = tc.price + } + d := schema.TestResourceDataRaw(t, resourceLiteLLMModel().Schema, raw) + client := NewClient(srv.URL, "test-key", false) + if err := resourceLiteLLMModelCreate(d, client); err != nil { + t.Fatal(err) + } + info := payload["model_info"].(map[string]interface{}) + got, present := info["cache_read_input_token_cost"] + if present != tc.configured || (present && got != tc.price/1000000) { + t.Fatalf("price = %v, present = %t", got, present) + } + if !tc.configured { + return + } + d.Set("cache_read_input_cost_per_million_tokens", 0.0) + if err := resourceLiteLLMModelUpdate(d, client); err != nil { + t.Fatal(err) + } + if got := payload["model_info"].(map[string]interface{})["cache_read_input_token_cost"]; got != 0.0 { + t.Fatalf("update zero = %v", got) + } + payload["model_info"].(map[string]interface{})["cache_read_input_token_cost"] = 0.000002 + if err := resourceLiteLLMModelRead(d, client); err != nil { + t.Fatal(err) + } + if d.Get("cache_read_input_cost_per_million_tokens") != 2.0 { + t.Fatal("refresh did not read changed pricing") + } + imported := schema.TestResourceDataRaw(t, resourceLiteLLMModel().Schema, nil) + imported.SetId(d.Id()) + if err := resourceLiteLLMModelRead(imported, client); err != nil { + t.Fatal(err) + } + if imported.Get("cache_read_input_cost_per_million_tokens") != 2.0 { + t.Fatal("import did not recover pricing") + } + }) + } } diff --git a/terraform/provider/litellm/resource_model_catalog_metadata_test.go b/terraform/provider/litellm/resource_model_catalog_metadata_test.go new file mode 100644 index 00000000000..c27a2035371 --- /dev/null +++ b/terraform/provider/litellm/resource_model_catalog_metadata_test.go @@ -0,0 +1,266 @@ +package litellm + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +var catalogModelInfo = map[string]interface{}{ + "supports_vision": true, + "input_cost_per_character": 0.00000125, + "default_voice": "alloy", + "probe_language": "pt-BR", + "probe_text": "Teste de saude", + "probe_skip": true, + "max_tokens": 8192, +} + +func requireCatalogModelSchema(t *testing.T) *schema.Resource { + t.Helper() + resource := resourceLiteLLMModel() + want := map[string]schema.ValueType{ + "supports_vision": schema.TypeBool, + "input_cost_per_character": schema.TypeFloat, + "default_voice": schema.TypeString, + "probe_language": schema.TypeString, + "probe_text": schema.TypeString, + "probe_skip": schema.TypeBool, + "max_tokens": schema.TypeInt, + } + for name, valueType := range want { + field, ok := resource.Schema[name] + if !ok { + t.Errorf("catalog RED: model schema missing %q", name) + continue + } + if field.Type != valueType { + t.Errorf("catalog RED: model schema %q type = %v, want %v", name, field.Type, valueType) + } + if !field.Optional || !field.Computed || field.Required { + t.Errorf("catalog RED: model schema %q must be Optional+Computed and not required", name) + } + } + return resource +} + +func requireCatalogFields(t *testing.T, resource *schema.Resource) { + t.Helper() + for name := range catalogModelInfo { + if resource.Schema[name] == nil { + t.Skipf("catalog request/read assertions require RED schema field %q", name) + } + } +} + +func assertCatalogModelInfoState(t *testing.T, d *schema.ResourceData, label string) { + t.Helper() + for name, want := range catalogModelInfo { + if got := d.Get(name); !reflect.DeepEqual(got, want) { + t.Errorf("%s %s = %#v, want %#v", label, name, got, want) + } + } +} + +func TestCatalogModelSchemaExposesTypedModelInfo(t *testing.T) { + requireCatalogModelSchema(t) +} + +func TestCatalogModelCreateSerializesTypedModelInfo(t *testing.T) { + resource := requireCatalogModelSchema(t) + requireCatalogFields(t, resource) + + var request map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == endpointModelNew { + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatalf("decode model request: %v", err) + } + _ = json.NewEncoder(w).Encode(request) + return + } + _ = json.NewEncoder(w).Encode(s04WrappedModelResponse(request, r.URL.Query().Get("modelId"))) + })) + defer srv.Close() + + raw := map[string]interface{}{ + "model_name": "catalog-model", "custom_llm_provider": "openai", + "base_model": "catalog-upstream", "mode": "chat", + } + for name, value := range catalogModelInfo { + raw[name] = value + } + d := schema.TestResourceDataRaw(t, resource.Schema, raw) + if err := createOrUpdateModel(d, NewClient(srv.URL, "test-key", false), false); err != nil { + t.Fatalf("create model: %v", err) + } + got, _ := request["model_info"].(map[string]interface{}) + for name, want := range catalogModelInfo { + if !s04JSONEqual(got[name], want) { + t.Errorf("model_info.%s = %#v, want %#v", name, got[name], want) + } + } +} + +func TestCatalogModelUpdateSerializesExplicitFalseAndZero(t *testing.T) { + resource := requireCatalogModelSchema(t) + requireCatalogFields(t, resource) + + var request map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/model/catalog-model-id/update" && r.Method == http.MethodPatch { + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatalf("decode model update: %v", err) + } + _ = json.NewEncoder(w).Encode(request) + return + } + _ = json.NewEncoder(w).Encode(s04WrappedModelResponse(request, r.URL.Query().Get("modelId"))) + })) + defer srv.Close() + + d := schema.TestResourceDataRaw(t, resource.Schema, map[string]interface{}{ + "model_name": "catalog-model", "custom_llm_provider": "openai", + "base_model": "catalog-upstream", "mode": "chat", + "supports_vision": false, "probe_skip": false, + "input_cost_per_character": 0.0, "max_tokens": 0, + }) + d.SetId("catalog-model-id") + if err := createOrUpdateModel(d, NewClient(srv.URL, "test-key", false), true); err != nil { + t.Fatalf("update model: %v", err) + } + got, _ := request["model_info"].(map[string]interface{}) + for _, name := range []string{"supports_vision", "probe_skip"} { + value, exists := got[name] + if !exists || value != false { + t.Errorf("model_info.%s = %#v (exists %t), want explicit false", name, value, exists) + } + } + for _, name := range []string{"input_cost_per_character", "max_tokens"} { + value, exists := got[name] + if !exists || value != float64(0) { + t.Errorf("model_info.%s = %#v (exists %t), want explicit zero", name, value, exists) + } + } +} + +func TestCatalogModelReadPreservesTypedMetadataOmittedByAPI(t *testing.T) { + resource := requireCatalogModelSchema(t) + requireCatalogFields(t, resource) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + response := map[string]interface{}{ + "model_name": "catalog-model", + "litellm_params": map[string]interface{}{ + "custom_llm_provider": "openai", "model": "openai/catalog-upstream", + }, + "model_info": map[string]interface{}{}, + } + _ = json.NewEncoder(w).Encode(s04WrappedModelResponse(response, r.URL.Query().Get("modelId"))) + })) + defer srv.Close() + + raw := map[string]interface{}{ + "model_name": "catalog-model", "custom_llm_provider": "openai", + "base_model": "catalog-upstream", "mode": "chat", + } + for name, value := range catalogModelInfo { + raw[name] = value + } + d := schema.TestResourceDataRaw(t, resource.Schema, raw) + d.SetId("catalog-model-id") + if err := resourceLiteLLMModelRead(d, NewClient(srv.URL, "test-key", false)); err != nil { + t.Fatalf("read model with omitted metadata: %v", err) + } + assertCatalogModelInfoState(t, d, "omitted API metadata") + for _, field := range []string{"input_modalities.#", "output_modalities.#"} { + if d.State().Attributes[field] != "0" { + t.Fatalf("missing empty modality state for %s", field) + } + } +} + +func TestCatalogModelReadAdoptsAPIPopulatedMetadataWithoutConfigDrift(t *testing.T) { + resource := requireCatalogModelSchema(t) + requireCatalogFields(t, resource) + + apiModelInfo := map[string]interface{}{ + "supports_vision": false, + "input_cost_per_character": 0.0, + "default_voice": "alloy", + "probe_language": "pt-BR", + "probe_text": "Teste de saude", + "probe_skip": false, + "max_tokens": 204800, + } + response := map[string]interface{}{ + "model_name": "catalog-model", + "litellm_params": map[string]interface{}{ + "custom_llm_provider": "openai", "model": "openai/catalog-upstream", + }, + "model_info": apiModelInfo, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(s04WrappedModelResponse(response, "catalog-model-id")) + })) + defer srv.Close() + + d := schema.TestResourceDataRaw(t, resource.Schema, map[string]interface{}{ + "model_name": "catalog-model", "custom_llm_provider": "openai", + "base_model": "catalog-upstream", "mode": "chat", + }) + d.SetId("catalog-model-id") + for read := 1; read <= 2; read++ { + if err := resourceLiteLLMModelRead(d, NewClient(srv.URL, "test-key", false)); err != nil { + t.Fatalf("read %d model with API-populated metadata: %v", read, err) + } + for name, want := range apiModelInfo { + if got := d.Get(name); !reflect.DeepEqual(got, want) { + t.Errorf("read %d API-populated %s = %#v, want %#v", read, name, got, want) + } + } + } +} + +func TestCatalogImportedModelRestoresTypedMetadataAcrossRepeatedReads(t *testing.T) { + resource := requireCatalogModelSchema(t) + requireCatalogFields(t, resource) + if resource.Importer == nil || resource.Importer.StateContext == nil { + t.Fatal("catalog RED: model resource has no importer") + } + + response := map[string]interface{}{ + "model_name": "catalog-model", + "litellm_params": map[string]interface{}{ + "custom_llm_provider": "openai", "model": "openai/catalog-upstream", + }, + "model_info": catalogModelInfo, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(s04WrappedModelResponse(response, "catalog-model-id")) + })) + defer srv.Close() + + d := schema.TestResourceDataRaw(t, resource.Schema, map[string]interface{}{}) + d.SetId("catalog-model-id") + states, err := resource.Importer.StateContext(context.Background(), d, nil) + if err != nil || len(states) != 1 { + t.Fatalf("import model: states=%d err=%v", len(states), err) + } + for read := 1; read <= 2; read++ { + if err := resourceLiteLLMModelRead(states[0], NewClient(srv.URL, "test-key", false)); err != nil { + t.Fatalf("read %d imported model: %v", read, err) + } + assertCatalogModelInfoState(t, states[0], "import/read") + } +} diff --git a/terraform/provider/litellm/resource_model_crud.go b/terraform/provider/litellm/resource_model_crud.go index ee8f5a62cba..0aae2658537 100644 --- a/terraform/provider/litellm/resource_model_crud.go +++ b/terraform/provider/litellm/resource_model_crud.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "log" + "net/http" + "net/url" "strconv" "strings" "time" @@ -52,11 +54,73 @@ func retryModelRead(d *schema.ResourceData, m interface{}, maxRetries int) error const ( endpointModelNew = "/model/new" - endpointModelUpdate = "/model/update" - endpointModelInfo = "/model/info" + endpointModelUpdate = "/model/%s/update" + endpointModelInfo = "/v2/model/info" endpointModelDelete = "/model/delete" ) +func modelFieldConfigured(d *schema.ResourceData, name string) bool { + if _, configured := d.GetOkExists(name); configured { + return true + } + if d.HasChange(name) { + return true + } + raw := d.GetRawConfig() + if !raw.IsKnown() || raw.IsNull() || !raw.Type().HasAttribute(name) { + return false + } + return !raw.GetAttr(name).IsNull() +} + +func configuredModelInt(d *schema.ResourceData, name string) *int { + if !modelFieldConfigured(d, name) { + return nil + } + value := d.Get(name).(int) + return &value +} + +func configuredModelBool(d *schema.ResourceData, name string) *bool { + if !modelFieldConfigured(d, name) { + return nil + } + value := d.Get(name).(bool) + return &value +} + +func configuredModelFloat(d *schema.ResourceData, name string) *float64 { + if !modelFieldConfigured(d, name) { + return nil + } + value := d.Get(name).(float64) + return &value +} + +func configuredModelString(d *schema.ResourceData, name string) *string { + if !modelFieldConfigured(d, name) { + return nil + } + value := d.Get(name).(string) + return &value +} + +func configuredModelStringList(d *schema.ResourceData, name string) *[]string { + if !modelFieldConfigured(d, name) { + return nil + } + value := expandStringList(d.Get(name).([]interface{})) + return &value +} + +func modelRoutingBase(customLLMProvider, model string) string { + prefix := customLLMProvider + "/" + if customLLMProvider != "" && strings.HasPrefix(model, prefix) { + return strings.TrimPrefix(model, prefix) + } + return model +} + func createOrUpdateModel(d *schema.ResourceData, m interface{}, isUpdate bool) error { client, ok := m.(*Client) if !ok { @@ -241,28 +305,46 @@ func createOrUpdateModel(d *schema.ResourceData, m interface{}, isUpdate bool) e if credentialName := d.Get("litellm_credential_name").(string); credentialName != "" { litellmParams["litellm_credential_name"] = credentialName } + cacheReadInputCost := configuredModelFloat(d, "cache_read_input_cost_per_million_tokens") + if cacheReadInputCost != nil { + *cacheReadInputCost /= 1000000.0 + } modelReq := ModelRequest{ ModelName: d.Get("model_name").(string), LiteLLMParams: litellmParams, - ModelInfo: ModelInfo{ + ModelInfo: ModelInfoRequest{ ID: modelID, DBModel: true, BaseModel: pricingBaseModel, Tier: d.Get("tier").(string), Mode: d.Get("mode").(string), TeamID: d.Get("team_id").(string), - CacheReadInputTokenCost: d.Get("cache_read_input_cost_per_million_tokens").(float64) / 1000000.0, + MaxInputTokens: configuredModelInt(d, "max_input_tokens"), + MaxOutputTokens: configuredModelInt(d, "max_output_tokens"), + InputModalities: configuredModelStringList(d, "input_modalities"), + OutputModalities: configuredModelStringList(d, "output_modalities"), + SupportsReasoning: configuredModelBool(d, "supports_reasoning"), + SupportsFunctionCalling: configuredModelBool(d, "supports_function_calling"), + SupportsVision: configuredModelBool(d, "supports_vision"), + InputCostPerCharacter: configuredModelFloat(d, "input_cost_per_character"), + CacheReadInputTokenCost: cacheReadInputCost, + DefaultVoice: configuredModelString(d, "default_voice"), + ProbeLanguage: configuredModelString(d, "probe_language"), + ProbeText: configuredModelString(d, "probe_text"), + ProbeSkip: configuredModelBool(d, "probe_skip"), + MaxTokens: configuredModelInt(d, "max_tokens"), }, Additional: make(map[string]interface{}), } - endpoint := endpointModelNew + var resp *http.Response + var err error if isUpdate { - endpoint = endpointModelUpdate + resp, err = MakeRequest(client, "PATCH", fmt.Sprintf(endpointModelUpdate, url.PathEscape(modelID)), modelReq) + } else { + resp, err = MakeRequest(client, "POST", endpointModelNew, modelReq) } - - resp, err := MakeRequest(client, "POST", endpoint, modelReq) if err != nil { return fmt.Errorf("failed to %s model: %w", map[bool]string{true: "update", false: "create"}[isUpdate], err) } @@ -293,13 +375,14 @@ func resourceLiteLLMModelRead(d *schema.ResourceData, m interface{}) error { return fmt.Errorf("invalid type assertion for client") } - resp, err := MakeRequest(client, "GET", fmt.Sprintf("%s?litellm_model_id=%s", endpointModelInfo, d.Id()), nil) + query := url.Values{"modelId": []string{d.Id()}} + resp, err := MakeRequest(client, "GET", fmt.Sprintf("%s?%s", endpointModelInfo, query.Encode()), nil) if err != nil { return fmt.Errorf("failed to read model: %w", err) } defer resp.Body.Close() - modelResp, err := handleAPIResponse(resp, nil, client) + modelResp, err := handleModelInfoAPIResponse(resp, d.Id(), client) if err != nil { if err.Error() == "model_not_found" { d.SetId("") @@ -315,19 +398,109 @@ func resourceLiteLLMModelRead(d *schema.ResourceData, m interface{}) error { d.Set("rpm", GetIntValue(modelResp.LiteLLMParams.RPM, d.Get("rpm").(int))) d.Set("model_api_base", GetStringValue(modelResp.LiteLLMParams.APIBase, d.Get("model_api_base").(string))) d.Set("api_version", GetStringValue(modelResp.LiteLLMParams.APIVersion, d.Get("api_version").(string))) - // base_model / pricing_base_model read-back. When pricing_base_model is - // configured, model_info.base_model holds the PRICING key, so recover the - // routing base_model from state (not returned by the API) and read - // pricing_base_model from model_info. - if pbm, ok := d.GetOk("pricing_base_model"); ok && pbm.(string) != "" { - d.Set("base_model", d.Get("base_model").(string)) - d.Set("pricing_base_model", GetStringValue(modelResp.ModelInfo.BaseModel, pbm.(string))) - } else { - d.Set("base_model", GetStringValue(modelResp.ModelInfo.BaseModel, d.Get("base_model").(string))) + // Routing and pricing keys are independent. For imports, reconstruct the + // routing base from litellm_params.model by stripping exactly one matching + // provider prefix. A model name containing further slashes stays intact. + routingBaseModel := modelRoutingBase(modelResp.LiteLLMParams.CustomLLMProvider, modelResp.LiteLLMParams.Model) + if routingBaseModel == "" { + routingBaseModel = d.Get("base_model").(string) + } + if err := d.Set("base_model", routingBaseModel); err != nil { + return fmt.Errorf("failed to set base_model: %w", err) + } + + if modelResp.ModelInfo.BaseModel != nil { + pricingBaseModel := *modelResp.ModelInfo.BaseModel + if pricingBaseModel == routingBaseModel { + pricingBaseModel = "" + } + if err := d.Set("pricing_base_model", pricingBaseModel); err != nil { + return fmt.Errorf("failed to set pricing_base_model: %w", err) + } } 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.ModelInfo.MaxInputTokens != nil { + if err := d.Set("max_input_tokens", *modelResp.ModelInfo.MaxInputTokens); err != nil { + return fmt.Errorf("failed to set max_input_tokens: %w", err) + } + } + if modelResp.ModelInfo.MaxOutputTokens != nil { + if err := d.Set("max_output_tokens", *modelResp.ModelInfo.MaxOutputTokens); err != nil { + return fmt.Errorf("failed to set max_output_tokens: %w", err) + } + } + if modelResp.ModelInfo.InputModalities == nil { + if err := d.Set("input_modalities", d.Get("input_modalities")); err != nil { + return err + } + } + if modelResp.ModelInfo.InputModalities != nil { + if err := d.Set("input_modalities", *modelResp.ModelInfo.InputModalities); err != nil { + return fmt.Errorf("failed to set input_modalities: %w", err) + } + } + if modelResp.ModelInfo.OutputModalities == nil { + if err := d.Set("output_modalities", d.Get("output_modalities")); err != nil { + return err + } + } + if modelResp.ModelInfo.OutputModalities != nil { + if err := d.Set("output_modalities", *modelResp.ModelInfo.OutputModalities); err != nil { + return fmt.Errorf("failed to set output_modalities: %w", err) + } + } + if modelResp.ModelInfo.SupportsReasoning != nil { + if err := d.Set("supports_reasoning", *modelResp.ModelInfo.SupportsReasoning); err != nil { + return fmt.Errorf("failed to set supports_reasoning: %w", err) + } + } + if modelResp.ModelInfo.SupportsFunctionCalling != nil { + if err := d.Set("supports_function_calling", *modelResp.ModelInfo.SupportsFunctionCalling); err != nil { + return fmt.Errorf("failed to set supports_function_calling: %w", err) + } + } + if modelResp.ModelInfo.SupportsVision != nil { + if err := d.Set("supports_vision", *modelResp.ModelInfo.SupportsVision); err != nil { + return fmt.Errorf("failed to set supports_vision: %w", err) + } + } + if modelResp.ModelInfo.InputCostPerCharacter != nil { + if err := d.Set("input_cost_per_character", *modelResp.ModelInfo.InputCostPerCharacter); err != nil { + return fmt.Errorf("failed to set input_cost_per_character: %w", err) + } + } + if modelResp.ModelInfo.CacheReadInputTokenCost != nil { + if err := d.Set("cache_read_input_cost_per_million_tokens", *modelResp.ModelInfo.CacheReadInputTokenCost*1000000.0); err != nil { + return fmt.Errorf("failed to set cache_read_input_cost_per_million_tokens: %w", err) + } + } + if modelResp.ModelInfo.DefaultVoice != nil { + if err := d.Set("default_voice", *modelResp.ModelInfo.DefaultVoice); err != nil { + return fmt.Errorf("failed to set default_voice: %w", err) + } + } + if modelResp.ModelInfo.ProbeLanguage != nil { + if err := d.Set("probe_language", *modelResp.ModelInfo.ProbeLanguage); err != nil { + return fmt.Errorf("failed to set probe_language: %w", err) + } + } + if modelResp.ModelInfo.ProbeText != nil { + if err := d.Set("probe_text", *modelResp.ModelInfo.ProbeText); err != nil { + return fmt.Errorf("failed to set probe_text: %w", err) + } + } + if modelResp.ModelInfo.ProbeSkip != nil { + if err := d.Set("probe_skip", *modelResp.ModelInfo.ProbeSkip); err != nil { + return fmt.Errorf("failed to set probe_skip: %w", err) + } + } + if modelResp.ModelInfo.MaxTokens != nil { + if err := d.Set("max_tokens", *modelResp.ModelInfo.MaxTokens); err != nil { + return fmt.Errorf("failed to set max_tokens: %w", err) + } + } // Preserve credential name from state since it might not be returned by API d.Set("litellm_credential_name", d.Get("litellm_credential_name").(string)) @@ -342,7 +515,6 @@ func resourceLiteLLMModelRead(d *schema.ResourceData, m interface{}) error { // Store cost information d.Set("input_cost_per_million_tokens", d.Get("input_cost_per_million_tokens")) - d.Set("cache_read_input_cost_per_million_tokens", d.Get("cache_read_input_cost_per_million_tokens")) d.Set("output_cost_per_million_tokens", d.Get("output_cost_per_million_tokens")) // Handle thinking configuration diff --git a/terraform/provider/litellm/resource_model_s04_test.go b/terraform/provider/litellm/resource_model_s04_test.go new file mode 100644 index 00000000000..b9403b18ae7 --- /dev/null +++ b/terraform/provider/litellm/resource_model_s04_test.go @@ -0,0 +1,494 @@ +package litellm + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +var s04ModelInfo = map[string]interface{}{ + "team_id": "team-terraform-litellm", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "input_modalities": []interface{}{"text"}, + "output_modalities": []interface{}{"text"}, + "supports_reasoning": true, + "supports_function_calling": true, +} + +func s04JSONEqual(got, want interface{}) bool { + if wantInt, ok := want.(int); ok { + gotFloat, ok := got.(float64) + return ok && gotFloat == float64(wantInt) + } + return reflect.DeepEqual(got, want) +} + +func assertS04ModelInfoState(t *testing.T, d *schema.ResourceData, label string) { + t.Helper() + for name, want := range s04ModelInfo { + if got := d.Get(name); !reflect.DeepEqual(got, want) { + t.Errorf("%s %s = %#v, want %#v", label, name, got, want) + } + } +} + +func s04WrappedModelResponse(response map[string]interface{}, id string) map[string]interface{} { + wrappedModel := make(map[string]interface{}, len(response)) + for key, value := range response { + wrappedModel[key] = value + } + modelInfo, _ := response["model_info"].(map[string]interface{}) + wrappedInfo := make(map[string]interface{}, len(modelInfo)+1) + for key, value := range modelInfo { + wrappedInfo[key] = value + } + wrappedInfo["id"] = id + wrappedModel["model_info"] = wrappedInfo + return map[string]interface{}{"data": []interface{}{wrappedModel}} +} + +func requireS04ModelSchema(t *testing.T) *schema.Resource { + t.Helper() + resource := resourceLiteLLMModel() + want := map[string]schema.ValueType{ + "team_id": schema.TypeString, + "max_input_tokens": schema.TypeInt, + "max_output_tokens": schema.TypeInt, + "input_modalities": schema.TypeList, + "output_modalities": schema.TypeList, + "supports_reasoning": schema.TypeBool, + "supports_function_calling": schema.TypeBool, + } + for name, valueType := range want { + field, ok := resource.Schema[name] + if !ok { + t.Errorf("S04 RED: model schema missing %q", name) + continue + } + if field.Type != valueType { + t.Errorf("S04 RED: model schema %q type = %v, want %v", name, field.Type, valueType) + } + } + if field := resource.Schema["team_id"]; field != nil && (!field.Optional || !field.Computed || field.Required) { + t.Error("team_id must remain Optional+Computed for upstream compatibility; the homelab module enforces ownership") + } + return resource +} + +func TestS04ModelImporterPassesThroughStableID(t *testing.T) { + resource := resourceLiteLLMModel() + if resource.Importer == nil || resource.Importer.StateContext == nil { + t.Fatal("S04 RED: model resource has no importer") + } + d := schema.TestResourceDataRaw(t, resource.Schema, map[string]interface{}{}) + d.SetId("model-tf-canary-big-pickle") + states, err := resource.Importer.StateContext(context.Background(), d, nil) + if err != nil { + t.Fatalf("import stable model ID: %v", err) + } + if len(states) != 1 || states[0].Id() != "model-tf-canary-big-pickle" { + t.Fatalf("import did not preserve model ID: %#v", states) + } +} + +func TestS04ModelCreateSerializesTeamAndModelInfo(t *testing.T) { + resource := requireS04ModelSchema(t) + for name := range s04ModelInfo { + if resource.Schema[name] == nil { + t.Skip("remaining request assertions require the RED schema fields") + } + } + + var request map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == endpointModelNew { + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatalf("decode model request: %v", err) + } + _ = json.NewEncoder(w).Encode(request) + return + } + _ = json.NewEncoder(w).Encode(s04WrappedModelResponse(request, r.URL.Query().Get("modelId"))) + })) + defer srv.Close() + + raw := map[string]interface{}{ + "model_name": "tf-canary-big-pickle", "custom_llm_provider": "openai", + "base_model": "big-pickle", "mode": "chat", + } + for name, value := range s04ModelInfo { + raw[name] = value + } + d := schema.TestResourceDataRaw(t, resource.Schema, raw) + if err := createOrUpdateModel(d, NewClient(srv.URL, "test-key", false), false); err != nil { + t.Fatalf("create model: %v", err) + } + got, _ := request["model_info"].(map[string]interface{}) + for name, want := range s04ModelInfo { + if !s04JSONEqual(got[name], want) { + t.Errorf("model_info.%s = %#v, want %#v", name, got[name], want) + } + } +} + +func TestS04ModelUpdateSerializesFalseCapabilities(t *testing.T) { + resource := requireS04ModelSchema(t) + var request map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/model/model-tf-canary-big-pickle/update" && r.Method == http.MethodPatch { + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatalf("decode model update: %v", err) + } + _ = json.NewEncoder(w).Encode(request) + return + } + _ = json.NewEncoder(w).Encode(s04WrappedModelResponse(request, r.URL.Query().Get("modelId"))) + })) + defer srv.Close() + + d := schema.TestResourceDataRaw(t, resource.Schema, map[string]interface{}{ + "model_name": "tf-canary-big-pickle", "custom_llm_provider": "openai", + "base_model": "big-pickle", "mode": "chat", "team_id": "team-terraform-litellm", + "supports_reasoning": false, "supports_function_calling": false, + "max_input_tokens": 0, "max_output_tokens": 0, + }) + d.SetId("model-tf-canary-big-pickle") + if err := createOrUpdateModel(d, NewClient(srv.URL, "test-key", false), true); err != nil { + t.Fatalf("update model: %v", err) + } + got, _ := request["model_info"].(map[string]interface{}) + for _, name := range []string{"supports_reasoning", "supports_function_calling"} { + value, exists := got[name] + if !exists || value != false { + t.Errorf("model_info.%s = %#v (exists %t), want explicit false", name, value, exists) + } + } + for _, name := range []string{"max_input_tokens", "max_output_tokens"} { + if value, exists := got[name]; !exists || value != float64(0) { + t.Errorf("model_info.%s = %#v (exists %t), want explicit zero", name, value, exists) + } + } + for _, name := range []string{"input_modalities", "output_modalities"} { + if value, exists := got[name]; exists { + t.Errorf("model_info.%s unexpectedly serialized as %#v; empty preserves remote metadata", name, value) + } + } +} + +func TestS04RoutingBaseStripsOneExactProviderPrefix(t *testing.T) { + if got := modelRoutingBase("openai", "openai/vendor/big-pickle"); got != "vendor/big-pickle" { + t.Fatalf("routing base = %q, want vendor/big-pickle", got) + } + if got := modelRoutingBase("openai", "other/openai/big-pickle"); got != "other/openai/big-pickle" { + t.Fatalf("nonmatching routing base mangled to %q", got) + } +} + +func TestS04LegacyModelUpdateOmitsUnconfiguredMetadata(t *testing.T) { + resource := requireS04ModelSchema(t) + var request map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/model/legacy-model-id/update" && r.Method == http.MethodPatch { + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatalf("decode legacy model update: %v", err) + } + _ = json.NewEncoder(w).Encode(request) + return + } + _ = json.NewEncoder(w).Encode(s04WrappedModelResponse(request, r.URL.Query().Get("modelId"))) + })) + defer srv.Close() + + d := schema.TestResourceDataRaw(t, resource.Schema, map[string]interface{}{ + "model_name": "legacy-model", "custom_llm_provider": "openai", + "base_model": "legacy", "mode": "chat", + }) + d.SetId("legacy-model-id") + if err := createOrUpdateModel(d, NewClient(srv.URL, "test-key", false), true); err != nil { + t.Fatalf("update legacy model: %v", err) + } + got, _ := request["model_info"].(map[string]interface{}) + for _, name := range []string{ + "max_input_tokens", "max_output_tokens", "input_modalities", "output_modalities", + "supports_reasoning", "supports_function_calling", + } { + if value, exists := got[name]; exists { + t.Errorf("legacy model_info.%s unexpectedly serialized as %#v", name, value) + } + } +} + +func TestS04ModelReadPreservesMetadataOmittedByAPI(t *testing.T) { + resource := requireS04ModelSchema(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + response := map[string]interface{}{ + "model_name": "tf-canary-big-pickle", + "litellm_params": map[string]interface{}{ + "custom_llm_provider": "openai", "model": "openai/big-pickle", + }, + "model_info": map[string]interface{}{"team_id": "team-terraform-litellm"}, + } + _ = json.NewEncoder(w).Encode(s04WrappedModelResponse(response, r.URL.Query().Get("modelId"))) + })) + defer srv.Close() + + raw := map[string]interface{}{ + "model_name": "tf-canary-big-pickle", "custom_llm_provider": "openai", + "base_model": "big-pickle", "mode": "chat", + } + for name, value := range s04ModelInfo { + raw[name] = value + } + d := schema.TestResourceDataRaw(t, resource.Schema, raw) + d.SetId("model-tf-canary-big-pickle") + if err := resourceLiteLLMModelRead(d, NewClient(srv.URL, "test-key", false)); err != nil { + t.Fatalf("read model with omitted metadata: %v", err) + } + assertS04ModelInfoState(t, d, "omitted API metadata") +} + +func TestS04ModelReadClearsStalePricingBaseWhenAuthoritativeValueMatchesRouting(t *testing.T) { + resource := requireS04ModelSchema(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + response := map[string]interface{}{ + "model_name": "tf-canary-big-pickle", + "litellm_params": map[string]interface{}{ + "custom_llm_provider": "openai", "model": "openai/big-pickle", + }, + "model_info": map[string]interface{}{"base_model": "big-pickle"}, + } + _ = json.NewEncoder(w).Encode(s04WrappedModelResponse(response, r.URL.Query().Get("modelId"))) + })) + defer srv.Close() + + d := schema.TestResourceDataRaw(t, resource.Schema, map[string]interface{}{ + "model_name": "tf-canary-big-pickle", "custom_llm_provider": "openai", + "base_model": "big-pickle", "pricing_base_model": "stale/pricing-key", "mode": "chat", + }) + d.SetId("model-tf-canary-big-pickle") + if err := resourceLiteLLMModelRead(d, NewClient(srv.URL, "test-key", false)); err != nil { + t.Fatalf("read model: %v", err) + } + if got := d.Get("pricing_base_model"); got != "" { + t.Fatalf("pricing_base_model = %#v, want cleared", got) + } +} + +func TestS04ModelReadPreservesPricingBaseWhenAPIValueIsOmitted(t *testing.T) { + resource := requireS04ModelSchema(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + response := map[string]interface{}{ + "model_name": "tf-canary-big-pickle", + "litellm_params": map[string]interface{}{ + "custom_llm_provider": "openai", "model": "openai/big-pickle", + }, + "model_info": map[string]interface{}{}, + } + _ = json.NewEncoder(w).Encode(s04WrappedModelResponse(response, r.URL.Query().Get("modelId"))) + })) + defer srv.Close() + + d := schema.TestResourceDataRaw(t, resource.Schema, map[string]interface{}{ + "model_name": "tf-canary-big-pickle", "custom_llm_provider": "openai", + "base_model": "big-pickle", "pricing_base_model": "preserved/pricing-key", "mode": "chat", + }) + d.SetId("model-tf-canary-big-pickle") + if err := resourceLiteLLMModelRead(d, NewClient(srv.URL, "test-key", false)); err != nil { + t.Fatalf("read model: %v", err) + } + if got := d.Get("pricing_base_model"); got != "preserved/pricing-key" { + t.Fatalf("pricing_base_model = %#v, want preserved/pricing-key", got) + } +} + +func TestS04ModelReadBackRestoresMetadataWithoutDiff(t *testing.T) { + resource := requireS04ModelSchema(t) + for name := range s04ModelInfo { + if resource.Schema[name] == nil { + t.Skip("remaining read-back assertions require the RED schema fields") + } + } + + response := map[string]interface{}{ + "model_name": "tf-canary-big-pickle", + "litellm_params": map[string]interface{}{ + "custom_llm_provider": "openai", "model": "openai/big-pickle", + }, + "model_info": s04ModelInfo, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(s04WrappedModelResponse(response, r.URL.Query().Get("modelId"))) + })) + defer srv.Close() + + raw := map[string]interface{}{ + "model_name": "tf-canary-big-pickle", "custom_llm_provider": "openai", + "base_model": "big-pickle", "mode": "chat", + } + for name, value := range s04ModelInfo { + raw[name] = value + } + d := schema.TestResourceDataRaw(t, resource.Schema, raw) + d.SetId("model-tf-canary-big-pickle") + if err := resourceLiteLLMModelRead(d, NewClient(srv.URL, "test-key", false)); err != nil { + t.Fatalf("read model: %v", err) + } + assertS04ModelInfoState(t, d, "read-back") + if err := resourceLiteLLMModelRead(d, NewClient(srv.URL, "test-key", false)); err != nil { + t.Fatalf("second read model: %v", err) + } + assertS04ModelInfoState(t, d, "second read-back") +} + +func TestS04ImportedModelReadBackIsStableAcrossRepeatedReads(t *testing.T) { + resource := requireS04ModelSchema(t) + if resource.Importer == nil || resource.Importer.StateContext == nil { + t.Fatal("S04 RED: imported model cannot be read because importer is missing") + } + for name := range s04ModelInfo { + if resource.Schema[name] == nil { + t.Skip("import/read zero-diff assertion requires the RED schema fields") + } + } + + modelInfo := make(map[string]interface{}, len(s04ModelInfo)+1) + for name, value := range s04ModelInfo { + modelInfo[name] = value + } + modelInfo["base_model"] = "openrouter/big-pickle-pricing" + response := map[string]interface{}{ + "model_name": "tf-canary-big-pickle", + "litellm_params": map[string]interface{}{ + "custom_llm_provider": "openai", "model": "openai/big-pickle", + }, + "model_info": modelInfo, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != endpointModelInfo || r.URL.Query().Get("modelId") != "model-tf-canary-big-pickle" { + t.Errorf("import read request = %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(s04WrappedModelResponse(response, "model-tf-canary-big-pickle")) + })) + defer srv.Close() + + d := schema.TestResourceDataRaw(t, resource.Schema, map[string]interface{}{}) + d.SetId("model-tf-canary-big-pickle") + states, err := resource.Importer.StateContext(context.Background(), d, nil) + if err != nil || len(states) != 1 { + t.Fatalf("import model: states=%d err=%v", len(states), err) + } + if err := resourceLiteLLMModelRead(states[0], NewClient(srv.URL, "test-key", false)); err != nil { + t.Fatalf("read imported model: %v", err) + } + assertS04ModelInfoState(t, states[0], "import/read") + if got := states[0].Get("base_model"); got != "big-pickle" { + t.Errorf("import/read base_model = %#v, want routing base big-pickle", got) + } + if got := states[0].Get("pricing_base_model"); got != "openrouter/big-pickle-pricing" { + t.Errorf("import/read pricing_base_model = %#v, want distinct pricing key", got) + } + if err := resourceLiteLLMModelRead(states[0], NewClient(srv.URL, "test-key", false)); err != nil { + t.Fatalf("second read imported model: %v", err) + } + assertS04ModelInfoState(t, states[0], "second import/read") + if got := states[0].Get("base_model"); got != "big-pickle" { + t.Errorf("second import/read base_model = %#v, want stable routing base", got) + } + if got := states[0].Get("pricing_base_model"); got != "openrouter/big-pickle-pricing" { + t.Errorf("second import/read pricing_base_model = %#v, want stable pricing key", got) + } +} + +func TestS04ImportedModelEmptyV2DataClearsID(t *testing.T) { + resource := requireS04ModelSchema(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != endpointModelInfo || r.URL.Query().Get("modelId") != "missing/model with space" { + t.Errorf("not-found read request = %s?%s", r.URL.Path, r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + if r.URL.RawQuery != "modelId=missing%2Fmodel+with+space" { + t.Errorf("model ID not URL encoded: %s", r.URL.RawQuery) + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"data": []interface{}{}}) + })) + defer srv.Close() + + d := schema.TestResourceDataRaw(t, resource.Schema, map[string]interface{}{}) + d.SetId("missing/model with space") + states, err := resource.Importer.StateContext(context.Background(), d, nil) + if err != nil || len(states) != 1 { + t.Fatalf("import missing model: states=%d err=%v", len(states), err) + } + if err := resourceLiteLLMModelRead(states[0], NewClient(srv.URL, "test-key", false)); err != nil { + t.Fatalf("read missing imported model: %v", err) + } + if states[0].Id() != "" { + t.Fatalf("missing imported model ID = %q, want cleared", states[0].Id()) + } +} + +func TestS04ModelReadRejectsMismatchedAndMultipleV2Data(t *testing.T) { + tests := map[string][]interface{}{ + "mismatched": { + map[string]interface{}{"model_info": map[string]interface{}{"id": "other-model"}}, + }, + "multiple": { + map[string]interface{}{"model_info": map[string]interface{}{"id": "expected-model"}}, + map[string]interface{}{"model_info": map[string]interface{}{"id": "other-model"}}, + }, + } + for name, data := range tests { + t.Run(name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"data": data}) + })) + defer srv.Close() + + resource := resourceLiteLLMModel() + d := schema.TestResourceDataRaw(t, resource.Schema, map[string]interface{}{}) + d.SetId("expected-model") + if err := resourceLiteLLMModelRead(d, NewClient(srv.URL, "test-key", false)); err == nil { + t.Fatal("read accepted ambiguous or mismatched v2 model data") + } + if d.Id() != "expected-model" { + t.Fatalf("failed read changed model ID to %q", d.Id()) + } + }) + } +} + +func TestS04ModelReadDoesNotAcceptUnwrappedZeroStruct(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "model_name": "wrong-unwrapped-shape", + "model_info": map[string]interface{}{"id": "expected-model"}, + }) + })) + defer srv.Close() + + resource := resourceLiteLLMModel() + d := schema.TestResourceDataRaw(t, resource.Schema, map[string]interface{}{}) + d.SetId("expected-model") + if err := resourceLiteLLMModelRead(d, NewClient(srv.URL, "test-key", false)); err != nil { + t.Fatalf("unwrapped response should be treated as not found, got: %v", err) + } + if d.Id() != "" { + t.Fatalf("unwrapped response produced false success with ID %q", d.Id()) + } +} diff --git a/terraform/provider/litellm/types.go b/terraform/provider/litellm/types.go index 92302efdcdd..b7f5a6b1925 100644 --- a/terraform/provider/litellm/types.go +++ b/terraform/provider/litellm/types.go @@ -21,15 +21,20 @@ type ErrorResponse struct { type ModelResponse struct { ModelName string `json:"model_name"` LiteLLMParams LiteLLMParams `json:"litellm_params"` - ModelInfo ModelInfo `json:"model_info"` + ModelInfo ModelInfoResponse `json:"model_info"` Additional map[string]interface{} `json:"additional"` } +// ModelInfoListResponse is returned by GET /v2/model/info. +type ModelInfoListResponse struct { + Data []ModelResponse `json:"data"` +} + // ModelRequest represents a request to create or update a model. type ModelRequest struct { ModelName string `json:"model_name"` LiteLLMParams map[string]interface{} `json:"litellm_params"` - ModelInfo ModelInfo `json:"model_info"` + ModelInfo ModelInfoRequest `json:"model_info"` Additional map[string]interface{} `json:"additional"` } @@ -106,15 +111,54 @@ type LiteLLMParams struct { VertexCredentials string `json:"vertex_credentials,omitempty"` } -// 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"` - CacheReadInputTokenCost float64 `json:"cache_read_input_token_cost,omitempty"` +// ModelInfoRequest is serialized for create/update. Clearable values are not +// omitted: an explicit zero, empty list, or false must reach LiteLLM. +type ModelInfoRequest 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"` + MaxInputTokens *int `json:"max_input_tokens,omitempty"` + MaxOutputTokens *int `json:"max_output_tokens,omitempty"` + InputModalities *[]string `json:"input_modalities,omitempty"` + OutputModalities *[]string `json:"output_modalities,omitempty"` + SupportsReasoning *bool `json:"supports_reasoning,omitempty"` + SupportsFunctionCalling *bool `json:"supports_function_calling,omitempty"` + SupportsVision *bool `json:"supports_vision,omitempty"` + InputCostPerCharacter *float64 `json:"input_cost_per_character,omitempty"` + CacheReadInputTokenCost *float64 `json:"cache_read_input_token_cost,omitempty"` + DefaultVoice *string `json:"default_voice,omitempty"` + ProbeLanguage *string `json:"probe_language,omitempty"` + ProbeText *string `json:"probe_text,omitempty"` + ProbeSkip *bool `json:"probe_skip,omitempty"` + MaxTokens *int `json:"max_tokens,omitempty"` +} + +// ModelInfoResponse distinguishes an omitted API field from an explicit zero, +// empty list, or false. Omitted values preserve the configured Terraform state. +type ModelInfoResponse 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"` + MaxInputTokens *int `json:"max_input_tokens"` + MaxOutputTokens *int `json:"max_output_tokens"` + InputModalities *[]string `json:"input_modalities"` + OutputModalities *[]string `json:"output_modalities"` + SupportsReasoning *bool `json:"supports_reasoning"` + SupportsFunctionCalling *bool `json:"supports_function_calling"` + SupportsVision *bool `json:"supports_vision"` + InputCostPerCharacter *float64 `json:"input_cost_per_character"` + CacheReadInputTokenCost *float64 `json:"cache_read_input_token_cost"` + DefaultVoice *string `json:"default_voice"` + ProbeLanguage *string `json:"probe_language"` + ProbeText *string `json:"probe_text"` + ProbeSkip *bool `json:"probe_skip"` + MaxTokens *int `json:"max_tokens"` } // Key represents a LiteLLM API key. diff --git a/terraform/provider/litellm/utils.go b/terraform/provider/litellm/utils.go index 5e81766d3f3..19cfb196d3f 100644 --- a/terraform/provider/litellm/utils.go +++ b/terraform/provider/litellm/utils.go @@ -291,3 +291,37 @@ func handleVectorStoreAPIResponse(resp *http.Response, result interface{}, clien return nil } + +func handleModelInfoAPIResponse(resp *http.Response, expectedID string, client *Client) (*ModelResponse, error) { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read model info response body: %v", err) + } + + if resp.StatusCode != http.StatusOK { + var errResp ErrorResponse + if err := json.Unmarshal(bodyBytes, &errResp); err == nil && isModelNotFoundError(errResp) { + return nil, fmt.Errorf("model_not_found") + } + return nil, fmt.Errorf("model info request failed: Status: %s, Response: %s", + resp.Status, client.redactSensitiveData(string(bodyBytes))) + } + + var wrapper ModelInfoListResponse + if err := json.Unmarshal(bodyBytes, &wrapper); err != nil { + return nil, fmt.Errorf("failed to parse model info response: %v", err) + } + if len(wrapper.Data) == 0 { + return nil, fmt.Errorf("model_not_found") + } + if len(wrapper.Data) != 1 { + return nil, fmt.Errorf("model info returned %d records for id %q; expected exactly one", len(wrapper.Data), expectedID) + } + if wrapper.Data[0].ModelInfo.ID != expectedID { + return nil, fmt.Errorf("model info returned id %q for requested id %q", wrapper.Data[0].ModelInfo.ID, expectedID) + } + + return &wrapper.Data[0], nil +} + +// MakeRequest is a helper function to make HTTP requests diff --git a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt index 6bc8947e89f..9fc7104223c 100644 --- a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt +++ b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt @@ -92,7 +92,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 @@ -130,3 +129,7 @@ DELETE /team/{team_id}/callback/{callback_name} # known gap: team callback res GET /access_group/{access_group}/budget # known gap: budget attributes on litellm_access_group PUT /access_group/{access_group}/budget # known gap: budget attributes on litellm_access_group DELETE /access_group/{access_group}/budget # known gap: budget attributes on litellm_access_group + +# Legacy model endpoints; resources use DB-backed reads and metadata-aware PATCH +GET /model/info +POST /model/update