fix(terraform): persist model metadata

This commit is contained in:
Erik Bogado 2026-09-07 03:38:19 -03:00
parent c2f88a7246
commit fd254922e8
9 changed files with 1200 additions and 44 deletions

View file

@ -237,6 +237,9 @@ terraform import litellm_model.gpt4 <model-id>
```
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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