diff --git a/terraform/provider/README.md b/terraform/provider/README.md
index b392fd6279d..56b95f8916a 100644
--- a/terraform/provider/README.md
+++ b/terraform/provider/README.md
@@ -79,6 +79,7 @@ Here's an example of creating an API key with various options:
```hcl
resource "litellm_key" "example_key" {
+ key_type = "llm_api"
models = ["gpt-4", "claude-3.5-sonnet"]
max_budget = 100.0
user_id = "user123"
@@ -123,6 +124,7 @@ resource "litellm_key" "example_key" {
The litellm_key resource supports the following options:
+- key_type: Choose the key's default route access
- models: List of allowed models for this key
- max_budget: Maximum budget for the key
- user_id and team_id: Associate the key with a user and team
diff --git a/terraform/provider/litellm/client.go b/terraform/provider/litellm/client.go
index e68b8a3a80b..660190630a2 100644
--- a/terraform/provider/litellm/client.go
+++ b/terraform/provider/litellm/client.go
@@ -242,6 +242,10 @@ func (c *Client) parseKeyResponse(resp map[string]interface{}) (*Key, error) {
if s, ok := v.(string); ok {
createdKey.TokenID = s
}
+ case "key_type":
+ if s, ok := v.(string); ok {
+ createdKey.KeyType = s
+ }
case "models":
if models, ok := v.([]interface{}); ok {
createdKey.Models = make([]string, len(models))
diff --git a/terraform/provider/litellm/resource_key.go b/terraform/provider/litellm/resource_key.go
index b471cab73e8..0fafd9a7e2d 100644
--- a/terraform/provider/litellm/resource_key.go
+++ b/terraform/provider/litellm/resource_key.go
@@ -11,6 +11,7 @@ import (
"github.com/hashicorp/go-cty/cty"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+ "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
)
func resourceKey() *schema.Resource {
@@ -34,6 +35,14 @@ func resourceKey() *schema.Resource {
Type: schema.TypeString,
Computed: true,
},
+ "key_type": {
+ Type: schema.TypeString,
+ Optional: true,
+ Computed: true,
+ ForceNew: true,
+ ValidateFunc: validation.StringInSlice([]string{"llm_api", "management", "read_only", "default"}, false),
+ Description: "Type of key that determines its default allowed routes. Changing it creates a new key",
+ },
"models": {
Type: schema.TypeList,
Optional: true,
@@ -163,6 +172,7 @@ func resourceKey() *schema.Resource {
"allowed_routes": {
Type: schema.TypeList,
Optional: true,
+ Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"allowed_passthrough_routes": {
@@ -449,6 +459,7 @@ func resourceKeyDelete(ctx context.Context, d *schema.ResourceData, m interface{
}
func mapResourceDataToKey(d *schema.ResourceData, key *Key) {
+ key.KeyType = d.Get("key_type").(string)
key.Models = expandStringList(d.Get("models").([]interface{}))
if v, ok := d.GetOk("max_budget"); ok {
val := v.(float64)
@@ -504,6 +515,9 @@ func mapKeyToResourceData(d *schema.ResourceData, key *Key) {
// Note: "key" is write-only and must not be set here (Read operations).
// It is only set during Create so it is available during apply.
+ if key.KeyType != "" {
+ d.Set("key_type", key.KeyType)
+ }
if len(key.Models) > 0 {
d.Set("models", key.Models)
@@ -576,9 +590,7 @@ func mapKeyToResourceData(d *schema.ResourceData, key *Key) {
if len(key.EnforcedParams) > 0 {
d.Set("enforced_params", key.EnforcedParams)
}
- if len(key.AllowedRoutes) > 0 {
- d.Set("allowed_routes", key.AllowedRoutes)
- }
+ d.Set("allowed_routes", append([]string{}, key.AllowedRoutes...))
if len(key.AllowedPassthroughRoutes) > 0 {
d.Set("allowed_passthrough_routes", key.AllowedPassthroughRoutes)
}
diff --git a/terraform/provider/litellm/resource_key_test.go b/terraform/provider/litellm/resource_key_test.go
index b6e67360ad0..e2a50f4ed7f 100644
--- a/terraform/provider/litellm/resource_key_test.go
+++ b/terraform/provider/litellm/resource_key_test.go
@@ -22,6 +22,7 @@ func newKeyResourceData(t *testing.T, raw map[string]interface{}) *schema.Resour
func TestMapResourceDataToKeyNewFields(t *testing.T) {
d := newKeyResourceData(t, map[string]interface{}{
+ "key_type": "llm_api",
"budget_id": "budget-1",
"enforced_params": []interface{}{"user"},
"allowed_routes": []interface{}{"/chat/completions"},
@@ -36,6 +37,9 @@ func TestMapResourceDataToKeyNewFields(t *testing.T) {
key := &Key{}
mapResourceDataToKey(d, key)
+ if key.KeyType != "llm_api" {
+ t.Errorf("KeyType = %q, want llm_api", key.KeyType)
+ }
if key.BudgetID != "budget-1" {
t.Errorf("BudgetID = %q, want budget-1", key.BudgetID)
}
@@ -139,6 +143,7 @@ func TestParseKeyResponseNewFields(t *testing.T) {
client := NewClient("http://localhost:4000", "test-key", true)
resp := map[string]interface{}{
"key": "sk-test",
+ "key_type": "llm_api",
"budget_id": "budget-1",
"enforced_params": []interface{}{"user"},
"allowed_routes": []interface{}{"/chat/completions"},
@@ -154,6 +159,9 @@ func TestParseKeyResponseNewFields(t *testing.T) {
if err != nil {
t.Fatalf("parseKeyResponse returned error: %v", err)
}
+ if key.KeyType != "llm_api" {
+ t.Errorf("KeyType = %q, want llm_api", key.KeyType)
+ }
if key.BudgetID != "budget-1" || key.OrganizationID != "org-1" || key.ProjectID != "proj-1" {
t.Errorf("string fields not parsed: %+v", key)
}
@@ -183,7 +191,10 @@ func TestCreateKeySendsConfigSuppliedKey(t *testing.T) {
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
- d := newKeyResourceData(t, map[string]interface{}{"key": "sk-custom"})
+ d := newKeyResourceData(t, map[string]interface{}{
+ "key": "sk-custom",
+ "key_type": "llm_api",
+ })
diags := resourceKeyCreate(context.Background(), d, client)
if diags.HasError() {
@@ -192,11 +203,63 @@ func TestCreateKeySendsConfigSuppliedKey(t *testing.T) {
if captured["key"] != "sk-custom" {
t.Errorf("create payload key = %v, want sk-custom", captured["key"])
}
+ if captured["key_type"] != "llm_api" {
+ t.Errorf("create payload key_type = %v, want llm_api", captured["key_type"])
+ }
if d.Id() != "hash-1" {
t.Errorf("resource ID = %q, want hash-1", d.Id())
}
}
+func TestKeyTypeRejectsUnknownValue(t *testing.T) {
+ _, errs := resourceKey().Schema["key_type"].ValidateFunc("unrestricted", "key_type")
+ if len(errs) == 0 {
+ t.Fatal("key_type accepted an unknown value")
+ }
+}
+
+func TestKeyTypeChangeForcesReplacement(t *testing.T) {
+ res := resourceKey()
+ priorData := newKeyResourceData(t, map[string]interface{}{"key_type": "default"})
+ priorData.SetId("hash-1")
+ config := terraform.NewResourceConfigRaw(map[string]interface{}{"key_type": "llm_api"})
+ diff, err := res.Diff(context.Background(), priorData.State(), config, nil)
+ if err != nil {
+ t.Fatalf("diff failed: %v", err)
+ }
+ if diff == nil || !diff.RequiresNew() {
+ t.Fatalf("changing key_type must force replacement, diff = %+v", diff)
+ }
+}
+
+func TestKeyTypePresetRoutesDoNotDrift(t *testing.T) {
+ cases := map[string]struct {
+ read *Key
+ config map[string]interface{}
+ }{
+ "llm_api preset": {read: &Key{KeyType: "llm_api", AllowedRoutes: []string{"llm_api_routes"}}, config: map[string]interface{}{"key_type": "llm_api"}},
+ "default no routes": {read: &Key{KeyType: "default"}, config: map[string]interface{}{}},
+ }
+ for name, tc := range cases {
+ t.Run(name, func(t *testing.T) {
+ res := resourceKey()
+ priorData := newKeyResourceData(t, map[string]interface{}{})
+ priorData.SetId("hash-1")
+ if err := priorData.Set("server_metadata", serverKeyMetadata(tc.read.Metadata)); err != nil {
+ t.Fatalf("set server_metadata: %v", err)
+ }
+ mapKeyToResourceData(priorData, tc.read)
+ diff, err := res.Diff(context.Background(), priorData.State(), terraform.NewResourceConfigRaw(tc.config), nil)
+ if err != nil {
+ t.Fatalf("diff failed: %v", err)
+ }
+ if diff != nil && !diff.Empty() {
+ t.Fatalf("server-derived allowed_routes must not drift, diff = %+v", diff)
+ }
+ })
+ }
+}
+
// The proxy validates each model_max_budget entry as a BudgetConfig object and
// 500s on a bare number, so the JSON string must reach /key/generate as nested
// objects and the proxy's response must map back to equivalent JSON in state.
@@ -370,6 +433,7 @@ func TestGetKeyUnwrapsInfoEnvelope(t *testing.T) {
w.Write([]byte(`{
"key": "hash-1",
"info": {
+ "key_type": "llm_api",
"key_alias": "envelope-alias",
"models": ["gpt-4o-mini"],
"budget_id": "budget-1",
@@ -388,6 +452,9 @@ func TestGetKeyUnwrapsInfoEnvelope(t *testing.T) {
if key.KeyAlias != "envelope-alias" {
t.Errorf("KeyAlias = %q, want envelope-alias (info envelope not unwrapped)", key.KeyAlias)
}
+ if key.KeyType != "llm_api" {
+ t.Errorf("KeyType = %q, want llm_api", key.KeyType)
+ }
if key.BudgetID != "budget-1" || key.TeamID != "team-1" {
t.Errorf("nested fields not parsed: %+v", key)
}
diff --git a/terraform/provider/litellm/types.go b/terraform/provider/litellm/types.go
index a8784b8a6a9..8baf88edee1 100644
--- a/terraform/provider/litellm/types.go
+++ b/terraform/provider/litellm/types.go
@@ -131,6 +131,7 @@ type ModelInfo struct {
type Key struct {
Key string `json:"key,omitempty"`
TokenID string `json:"token_id,omitempty"`
+ KeyType string `json:"key_type,omitempty"`
Models []string `json:"models"`
Spend float64 `json:"spend,omitempty"`
MaxBudget *float64 `json:"max_budget,omitempty"`