mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
fix(terraform): persist model metadata
This commit is contained in:
parent
c2f88a7246
commit
fd254922e8
9 changed files with 1200 additions and 44 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
494
terraform/provider/litellm/resource_model_s04_test.go
Normal file
494
terraform/provider/litellm/resource_model_s04_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue