diff --git a/terraform/provider/CHANGELOG.md b/terraform/provider/CHANGELOG.md
index ad2bdb003f9..842bfb4bdb1 100644
--- a/terraform/provider/CHANGELOG.md
+++ b/terraform/provider/CHANGELOG.md
@@ -16,6 +16,7 @@ longer signal it.
### Added
+- **jwt_key_mapping**: New `litellm_jwt_key_mapping` resource for the proxy's JWT to virtual key mappings, so JWT clients identified by a claim (`client_id`, `azp`, `sub`) map to virtual keys and inherit their models, budgets and rate limits. Supports `description` and `is_active`, rotating the mapped key in place, and forces replacement when the claim name or value changes
- **team**: `soft_budget`, `tags`, and `soft_budget_alerting_emails` attributes on `litellm_team`, matching what `/team/new` and `/team/update` already accept; `soft_budget_alerting_emails` is sent under `metadata`, where the proxy reads it
- **user**: New `litellm_user` resource and `litellm_user` / `litellm_users` data sources for managing internal users
- **budget**: New `litellm_budget` resource and `litellm_budget` / `litellm_budgets` data sources for reusable budget objects
diff --git a/terraform/provider/README.md b/terraform/provider/README.md
index d781ccf8b3c..13578e25224 100644
--- a/terraform/provider/README.md
+++ b/terraform/provider/README.md
@@ -151,6 +151,7 @@ For full details on the litellm_key resource, see the [key resource
- litellm_mcp_server: Manage MCP (Model Context Protocol) servers. [Documentation](docs/resources/mcp_server.md)
- litellm_credential: Manage credentials for secure authentication. [Documentation](docs/resources/credential.md)
- litellm_vector_store: Manage vector stores for embeddings and RAG. [Documentation](docs/resources/vector_store.md)
+- litellm_jwt_key_mapping: Map JWT claim values to virtual keys for per-client budgets and limits. [Documentation](docs/resources/jwt_key_mapping.md)
### Available Data Sources
diff --git a/terraform/provider/docs/index.md b/terraform/provider/docs/index.md
index c03071e7ed3..e6641782a4d 100644
--- a/terraform/provider/docs/index.md
+++ b/terraform/provider/docs/index.md
@@ -51,6 +51,7 @@ The LiteLLM provider supports the following resources:
* [`litellm_mcp_server`](./resources/mcp_server) - Manage MCP (Model Context Protocol) servers
* [`litellm_credential`](./resources/credential) - Manage credentials for various providers
* [`litellm_vector_store`](./resources/vector_store) - Manage vector stores
+* [`litellm_jwt_key_mapping`](./resources/jwt_key_mapping) - Map JWT claim values to virtual keys
## Available Data Sources
diff --git a/terraform/provider/docs/resources/jwt_key_mapping.md b/terraform/provider/docs/resources/jwt_key_mapping.md
new file mode 100644
index 00000000000..fbc30947113
--- /dev/null
+++ b/terraform/provider/docs/resources/jwt_key_mapping.md
@@ -0,0 +1,94 @@
+# litellm_jwt_key_mapping
+
+Maps a JWT claim value to a LiteLLM virtual key. Every JWT client identified by a claim, typically `client_id`, `azp` or `sub`, then gets the model restrictions, budgets, rate limits, guardrails and spend tracking of the virtual key it maps to, without that key ever being handed to the client.
+
+The mappings only take effect once JWT auth is enabled on the proxy, which is configuration rather than API state:
+
+```yaml
+general_settings:
+ enable_jwt_auth: True
+ litellm_jwtauth:
+ virtual_key_claim_field: "client_id"
+ unregistered_jwt_client_behavior: "fallback_team_mapping"
+```
+
+See [JWT to virtual key mapping](https://docs.litellm.ai/docs/proxy/jwt_key_mapping) for the proxy side of the feature
+
+## Example Usage
+
+The mapped virtual key has to exist already and its value has to be known to Terraform, so it comes from a variable or a secret manager rather than from a `litellm_key` resource. `litellm_key` deliberately made its generated `key` write-only, to avoid storing raw API keys in state, so referencing it here does not merely read back null: Terraform's write-only enforcement turns `key = litellm_key.foo.key` into a static `Missing required argument` error at `terraform plan`, before any API call, in every apply ordering, including a first apply where both resources are created together:
+
+```hcl
+variable "alice_key" {
+ type = string
+ sensitive = true
+}
+
+resource "litellm_jwt_key_mapping" "alice" {
+ jwt_claim_name = "client_id"
+ jwt_claim_value = "dev-alice"
+ key = var.alice_key
+}
+```
+
+Per-client limits live on the virtual key, so one mapping per client is how each JWT client gets its own budget and quota:
+
+```hcl
+resource "litellm_jwt_key_mapping" "billing_service" {
+ jwt_claim_name = "client_id"
+ jwt_claim_value = "billing-service"
+ key = var.billing_service_key
+ description = "Billing service JWT client"
+ is_active = true
+}
+```
+
+Several clients at once, with the key values coming from a map of secrets:
+
+```hcl
+variable "jwt_client_keys" {
+ type = map(string)
+ sensitive = true
+}
+
+resource "litellm_jwt_key_mapping" "developer" {
+ for_each = var.jwt_client_keys
+
+ jwt_claim_name = "client_id"
+ jwt_claim_value = each.key
+ key = each.value
+ description = "Developer JWT client ${each.key}"
+}
+```
+
+## Argument Reference
+
+- `jwt_claim_name` - (Required, ForceNew) Name of the JWT claim to match on, for example `client_id`, `azp` or `sub`. Must match `virtual_key_claim_field` in the proxy JWT config
+- `jwt_claim_value` - (Required, ForceNew) Value of the claim identifying the JWT client. Unique together with `jwt_claim_name`, so a second mapping for the same pair fails with a 409
+- `key` - (Required, Sensitive) The virtual key this claim value maps to. It has to exist already, otherwise the proxy rejects the mapping with `The provided key does not match an existing virtual key`
+- `description` - (Optional) Description of the mapping
+- `is_active` - (Optional) Whether the mapping is active. Inactive mappings are ignored during JWT auth. Defaults to `true`
+
+## Attribute Reference
+
+- `id` - The mapping ID assigned by LiteLLM
+- `created_at` - Timestamp when the mapping was created
+- `updated_at` - Timestamp when the mapping was last updated
+- `created_by` - User who created the mapping
+- `updated_by` - User who last updated the mapping
+
+## Notes
+
+The proxy stores only a hash of `key` and never returns it, so drift on that attribute cannot be detected and Terraform tracks the value from your configuration. Changing `key` rotates the mapping onto the new virtual key in place, with no replacement. Like the other secrets this provider accepts, such as `credential_values` and `model_api_key`, the configured value is kept in state, so treat the state as sensitive
+
+Only proxy admins can create, update or delete mappings, so the provider `api_key` has to be a master key or an admin key
+
+## Import
+
+Mappings are imported by their mapping ID:
+
+```shell
+terraform import litellm_jwt_key_mapping.alice 297a5536-1aeb-4cf1-b666-b3809c2750a8
+```
+
+Because the API does not return the mapped key, `key` is empty in state right after an import, so the first plan shows an in-place update that pushes the configured key back to the proxy. That update is harmless, the proxy just rehashes the same value when the key has not actually changed
diff --git a/terraform/provider/litellm/provider.go b/terraform/provider/litellm/provider.go
index 17e2229517b..0afbbe9a464 100644
--- a/terraform/provider/litellm/provider.go
+++ b/terraform/provider/litellm/provider.go
@@ -19,6 +19,7 @@ func Provider() *schema.Provider {
"litellm_mcp_server": resourceLiteLLMMCPServer(),
"litellm_credential": resourceLiteLLMCredential(),
"litellm_vector_store": resourceLiteLLMVectorStore(),
+ "litellm_jwt_key_mapping": resourceLiteLLMJWTKeyMapping(),
"litellm_fallback": resourceLiteLLMFallback(),
"litellm_key_block": resourceLiteLLMKeyBlock(),
"litellm_team_block": resourceLiteLLMTeamBlock(),
diff --git a/terraform/provider/litellm/resource_jwt_key_mapping.go b/terraform/provider/litellm/resource_jwt_key_mapping.go
new file mode 100644
index 00000000000..e606e865737
--- /dev/null
+++ b/terraform/provider/litellm/resource_jwt_key_mapping.go
@@ -0,0 +1,70 @@
+package litellm
+
+import (
+ "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+)
+
+func resourceLiteLLMJWTKeyMapping() *schema.Resource {
+ return &schema.Resource{
+ Create: resourceLiteLLMJWTKeyMappingCreate,
+ Read: resourceLiteLLMJWTKeyMappingRead,
+ Update: resourceLiteLLMJWTKeyMappingUpdate,
+ Delete: resourceLiteLLMJWTKeyMappingDelete,
+
+ Importer: &schema.ResourceImporter{
+ StateContext: schema.ImportStatePassthroughContext,
+ },
+
+ Schema: map[string]*schema.Schema{
+ "jwt_claim_name": {
+ Type: schema.TypeString,
+ Required: true,
+ ForceNew: true,
+ Description: "Name of the JWT claim to match on, for example client_id, azp or sub. Must match virtual_key_claim_field in the proxy JWT config",
+ },
+ "jwt_claim_value": {
+ Type: schema.TypeString,
+ Required: true,
+ ForceNew: true,
+ Description: "Value of the claim identifying the JWT client. Unique together with jwt_claim_name",
+ },
+ "key": {
+ Type: schema.TypeString,
+ Required: true,
+ Sensitive: true,
+ Description: "The virtual key this claim value maps to. The proxy stores only a hash of it and never returns it, so drift on this attribute cannot be detected and Terraform tracks the configured value",
+ },
+ "description": {
+ Type: schema.TypeString,
+ Optional: true,
+ Description: "Description of the mapping",
+ },
+ "is_active": {
+ Type: schema.TypeBool,
+ Optional: true,
+ Default: true,
+ Description: "Whether the mapping is active. Inactive mappings are ignored during JWT auth",
+ },
+ "created_at": {
+ Type: schema.TypeString,
+ Computed: true,
+ Description: "Timestamp when the mapping was created",
+ },
+ "updated_at": {
+ Type: schema.TypeString,
+ Computed: true,
+ Description: "Timestamp when the mapping was last updated",
+ },
+ "created_by": {
+ Type: schema.TypeString,
+ Computed: true,
+ Description: "User who created the mapping",
+ },
+ "updated_by": {
+ Type: schema.TypeString,
+ Computed: true,
+ Description: "User who last updated the mapping",
+ },
+ },
+ }
+}
diff --git a/terraform/provider/litellm/resource_jwt_key_mapping_crud.go b/terraform/provider/litellm/resource_jwt_key_mapping_crud.go
new file mode 100644
index 00000000000..725235305f6
--- /dev/null
+++ b/terraform/provider/litellm/resource_jwt_key_mapping_crud.go
@@ -0,0 +1,186 @@
+package litellm
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+
+ "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+)
+
+const jwtKeyMappingNotFound = "jwt_key_mapping_not_found"
+
+func resourceLiteLLMJWTKeyMappingCreate(d *schema.ResourceData, m interface{}) error {
+ client := m.(*Client)
+
+ createRequest := JWTKeyMappingRequest{
+ JWTClaimName: d.Get("jwt_claim_name").(string),
+ JWTClaimValue: d.Get("jwt_claim_value").(string),
+ Key: d.Get("key").(string),
+ Description: d.Get("description").(string),
+ }
+
+ resp, err := MakeRequest(client, "POST", "/jwt/key/mapping/new", createRequest)
+ if err != nil {
+ return fmt.Errorf("failed to create JWT key mapping: %w", err)
+ }
+ defer resp.Body.Close()
+
+ var mapping JWTKeyMappingResponse
+ if err := handleJWTKeyMappingAPIResponse(resp, &mapping, client); err != nil {
+ return fmt.Errorf("failed to create JWT key mapping: %w", err)
+ }
+
+ if mapping.ID == "" {
+ return fmt.Errorf("failed to create JWT key mapping: the proxy returned no mapping id")
+ }
+
+ d.SetId(mapping.ID)
+
+ // The create endpoint has no is_active field and always activates the
+ // mapping, so a JWT client matching this claim can authenticate during
+ // the gap before the deactivation call below runs. If deactivation
+ // itself fails, delete the mapping rather than leaving it active and
+ // unmanaged indefinitely.
+ if !d.Get("is_active").(bool) {
+ if err := updateJWTKeyMapping(d, client); err != nil {
+ if deleteErr := deleteJWTKeyMapping(mapping.ID, client); deleteErr != nil {
+ return fmt.Errorf(
+ "JWT key mapping %s was created active and could not be deactivated (%v); it also could not be deleted and remains active on the proxy, remove it manually via POST /jwt/key/mapping/delete: %v",
+ mapping.ID, err, deleteErr,
+ )
+ }
+ d.SetId("")
+ return fmt.Errorf("JWT key mapping was created active but could not be deactivated, so it was deleted instead: %w", err)
+ }
+ }
+
+ return resourceLiteLLMJWTKeyMappingRead(d, m)
+}
+
+func resourceLiteLLMJWTKeyMappingRead(d *schema.ResourceData, m interface{}) error {
+ client := m.(*Client)
+
+ resp, err := MakeRequest(client, "GET", fmt.Sprintf("/jwt/key/mapping/info?id=%s", url.QueryEscape(d.Id())), nil)
+ if err != nil {
+ return fmt.Errorf("failed to read JWT key mapping: %w", err)
+ }
+ defer resp.Body.Close()
+
+ var mapping JWTKeyMappingResponse
+ if err := handleJWTKeyMappingAPIResponse(resp, &mapping, client); err != nil {
+ if err.Error() == jwtKeyMappingNotFound {
+ d.SetId("")
+ return nil
+ }
+ return fmt.Errorf("failed to read JWT key mapping: %w", err)
+ }
+
+ d.SetId(mapping.ID)
+ d.Set("jwt_claim_name", mapping.JWTClaimName)
+ d.Set("jwt_claim_value", mapping.JWTClaimValue)
+ d.Set("description", mapping.Description)
+ d.Set("is_active", mapping.IsActive)
+ d.Set("created_at", mapping.CreatedAt)
+ d.Set("updated_at", mapping.UpdatedAt)
+ d.Set("created_by", mapping.CreatedBy)
+ d.Set("updated_by", mapping.UpdatedBy)
+
+ return nil
+}
+
+func resourceLiteLLMJWTKeyMappingUpdate(d *schema.ResourceData, m interface{}) error {
+ client := m.(*Client)
+
+ oldKey, _ := d.GetChange("key")
+ oldDescription, _ := d.GetChange("description")
+ oldIsActive, _ := d.GetChange("is_active")
+
+ if err := updateJWTKeyMapping(d, client); err != nil {
+ // The update is a single atomic API call: on failure nothing changed
+ // server-side. Revert every field the update could have changed before
+ // attempting to resync, so a failed refresh can't leave the rejected
+ // values persisted into state.
+ d.Set("key", oldKey)
+ d.Set("description", oldDescription)
+ d.Set("is_active", oldIsActive)
+ if readErr := resourceLiteLLMJWTKeyMappingRead(d, m); readErr != nil {
+ return fmt.Errorf("failed to update JWT key mapping: %w (and failed to refresh state afterward: %v)", err, readErr)
+ }
+ return fmt.Errorf("failed to update JWT key mapping: %w", err)
+ }
+
+ return resourceLiteLLMJWTKeyMappingRead(d, m)
+}
+
+func resourceLiteLLMJWTKeyMappingDelete(d *schema.ResourceData, m interface{}) error {
+ client := m.(*Client)
+
+ if err := deleteJWTKeyMapping(d.Id(), client); err != nil {
+ return fmt.Errorf("failed to delete JWT key mapping: %w", err)
+ }
+
+ d.SetId("")
+ return nil
+}
+
+func deleteJWTKeyMapping(id string, client *Client) error {
+ resp, err := MakeRequest(client, "POST", "/jwt/key/mapping/delete", JWTKeyMappingDeleteRequest{ID: id})
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if err := handleJWTKeyMappingAPIResponse(resp, nil, client); err != nil {
+ if err.Error() != jwtKeyMappingNotFound {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func updateJWTKeyMapping(d *schema.ResourceData, client *Client) error {
+ updateRequest := JWTKeyMappingUpdateRequest{
+ ID: d.Id(),
+ Key: d.Get("key").(string),
+ Description: d.Get("description").(string),
+ IsActive: d.Get("is_active").(bool),
+ }
+
+ resp, err := MakeRequest(client, "POST", "/jwt/key/mapping/update", updateRequest)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ return handleJWTKeyMappingAPIResponse(resp, nil, client)
+}
+
+func handleJWTKeyMappingAPIResponse(resp *http.Response, result interface{}, client *Client) error {
+ bodyBytes, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("failed to read response body: %v", err)
+ }
+
+ if resp.StatusCode == http.StatusNotFound {
+ return fmt.Errorf(jwtKeyMappingNotFound)
+ }
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+ return fmt.Errorf("API request failed: Status: %s, Response: %s",
+ resp.Status, client.redactSensitiveData(string(bodyBytes)))
+ }
+
+ if result == nil {
+ return nil
+ }
+
+ if err := json.Unmarshal(bodyBytes, result); err != nil {
+ return fmt.Errorf("failed to parse response: %v", err)
+ }
+
+ return nil
+}
diff --git a/terraform/provider/litellm/resource_jwt_key_mapping_crud_test.go b/terraform/provider/litellm/resource_jwt_key_mapping_crud_test.go
new file mode 100644
index 00000000000..8007d1d4e08
--- /dev/null
+++ b/terraform/provider/litellm/resource_jwt_key_mapping_crud_test.go
@@ -0,0 +1,630 @@
+package litellm
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+ "github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
+)
+
+// resourceDataWithChange builds a ResourceData carrying a real diff between
+// prior state and new config, so d.GetChange reflects true old/new values.
+// schema.TestResourceDataRaw diffs against a nil prior state, which collapses
+// GetChange's old side to the zero value and can't exercise this.
+func resourceDataWithChange(t *testing.T, oldAttrs map[string]string, newRaw map[string]interface{}) *schema.ResourceData {
+ t.Helper()
+
+ sm := schema.InternalMap(resourceLiteLLMJWTKeyMapping().Schema)
+ state := &terraform.InstanceState{ID: oldAttrs["id"], Attributes: oldAttrs}
+ config := terraform.NewResourceConfigRaw(newRaw)
+
+ diff, err := sm.Diff(context.Background(), state, config, nil, nil, true)
+ if err != nil {
+ t.Fatalf("diff: %v", err)
+ }
+ d, err := sm.Data(state, diff)
+ if err != nil {
+ t.Fatalf("data: %v", err)
+ }
+ return d
+}
+
+type jwtKeyMappingCall struct {
+ Method string
+ Path string
+ Query string
+ Body map[string]interface{}
+}
+
+func jwtKeyMappingTestServer(t *testing.T, mapping JWTKeyMappingResponse) (*httptest.Server, *[]jwtKeyMappingCall) {
+ t.Helper()
+
+ calls := make([]jwtKeyMappingCall, 0)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body := map[string]interface{}{}
+ if r.Body != nil {
+ _ = json.NewDecoder(r.Body).Decode(&body)
+ }
+ calls = append(calls, jwtKeyMappingCall{Method: r.Method, Path: r.URL.Path, Query: r.URL.RawQuery, Body: body})
+
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/jwt/key/mapping/delete":
+ _ = json.NewEncoder(w).Encode(map[string]string{"status": "success"})
+ default:
+ _ = json.NewEncoder(w).Encode(mapping)
+ }
+ }))
+
+ return srv, &calls
+}
+
+func jwtKeyMappingFixture() JWTKeyMappingResponse {
+ return JWTKeyMappingResponse{
+ ID: "map-abc-123",
+ JWTClaimName: "client_id",
+ JWTClaimValue: "dev-alice",
+ Description: "dev-alice",
+ IsActive: true,
+ CreatedAt: "2026-08-06T10:00:00Z",
+ UpdatedAt: "2026-08-06T11:00:00Z",
+ CreatedBy: "admin",
+ UpdatedBy: "admin",
+ }
+}
+
+func TestJWTKeyMappingCreateSendsClaimAndKey(t *testing.T) {
+ srv, calls := jwtKeyMappingTestServer(t, jwtKeyMappingFixture())
+ defer srv.Close()
+
+ client := NewClient(srv.URL, "test-key", true)
+ d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "dev-alice",
+ "key": "sk-abc123",
+ "description": "dev-alice",
+ "is_active": true,
+ })
+
+ if err := resourceLiteLLMJWTKeyMappingCreate(d, client); err != nil {
+ t.Fatalf("create failed: %v", err)
+ }
+
+ if d.Id() != "map-abc-123" {
+ t.Fatalf("expected id from the API response, got %q", d.Id())
+ }
+
+ create := (*calls)[0]
+ if create.Method != "POST" || create.Path != "/jwt/key/mapping/new" {
+ t.Fatalf("expected POST /jwt/key/mapping/new, got %s %s", create.Method, create.Path)
+ }
+ if create.Body["jwt_claim_name"] != "client_id" || create.Body["jwt_claim_value"] != "dev-alice" {
+ t.Fatalf("claim fields not sent: %v", create.Body)
+ }
+ if create.Body["key"] != "sk-abc123" {
+ t.Fatalf("virtual key not sent: %v", create.Body["key"])
+ }
+ if create.Body["description"] != "dev-alice" {
+ t.Fatalf("description not sent: %v", create.Body["description"])
+ }
+ if _, sent := create.Body["is_active"]; sent {
+ t.Fatalf("is_active is not accepted by /jwt/key/mapping/new but was sent: %v", create.Body)
+ }
+
+ for _, call := range (*calls)[1:] {
+ if call.Path == "/jwt/key/mapping/update" {
+ t.Fatalf("an active mapping must not trigger a follow-up update")
+ }
+ }
+}
+
+func TestJWTKeyMappingCreateOmitsEmptyDescription(t *testing.T) {
+ srv, calls := jwtKeyMappingTestServer(t, jwtKeyMappingFixture())
+ defer srv.Close()
+
+ client := NewClient(srv.URL, "test-key", true)
+ d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "dev-alice",
+ "key": "sk-abc123",
+ "is_active": true,
+ })
+
+ if err := resourceLiteLLMJWTKeyMappingCreate(d, client); err != nil {
+ t.Fatalf("create failed: %v", err)
+ }
+
+ if _, sent := (*calls)[0].Body["description"]; sent {
+ t.Fatalf("unset description should be omitted: %v", (*calls)[0].Body)
+ }
+}
+
+func TestJWTKeyMappingCreateDeactivatesWhenNotActive(t *testing.T) {
+ mapping := jwtKeyMappingFixture()
+ mapping.IsActive = false
+ srv, calls := jwtKeyMappingTestServer(t, mapping)
+ defer srv.Close()
+
+ client := NewClient(srv.URL, "test-key", true)
+ d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "dev-alice",
+ "key": "sk-abc123",
+ "is_active": false,
+ })
+
+ if err := resourceLiteLLMJWTKeyMappingCreate(d, client); err != nil {
+ t.Fatalf("create failed: %v", err)
+ }
+
+ var update *jwtKeyMappingCall
+ for i := range *calls {
+ if (*calls)[i].Path == "/jwt/key/mapping/update" {
+ update = &(*calls)[i]
+ break
+ }
+ }
+ if update == nil {
+ t.Fatal("expected a follow-up update, since the create endpoint always starts a mapping active")
+ }
+ if update.Body["id"] != "map-abc-123" {
+ t.Fatalf("update must target the new mapping, got %v", update.Body["id"])
+ }
+ if update.Body["is_active"] != false {
+ t.Fatalf("expected is_active false in the follow-up update, got %v", update.Body["is_active"])
+ }
+ if d.Get("is_active").(bool) {
+ t.Fatal("state should reflect the inactive mapping after create")
+ }
+}
+
+func TestJWTKeyMappingCreateDeletesMappingWhenDeactivationFails(t *testing.T) {
+ // Regression test: the create endpoint has no is_active field and always
+ // activates the mapping, so a failed deactivation used to leave that
+ // mapping active and unmanaged indefinitely. It must be deleted instead.
+ calls := make([]jwtKeyMappingCall, 0)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body := map[string]interface{}{}
+ if r.Body != nil {
+ _ = json.NewDecoder(r.Body).Decode(&body)
+ }
+ calls = append(calls, jwtKeyMappingCall{Method: r.Method, Path: r.URL.Path, Query: r.URL.RawQuery, Body: body})
+
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/jwt/key/mapping/new":
+ _ = json.NewEncoder(w).Encode(jwtKeyMappingFixture())
+ case "/jwt/key/mapping/update":
+ w.WriteHeader(http.StatusInternalServerError)
+ _ = json.NewEncoder(w).Encode(map[string]string{"detail": "proxy unavailable"})
+ case "/jwt/key/mapping/delete":
+ _ = json.NewEncoder(w).Encode(map[string]string{"status": "success"})
+ default:
+ t.Fatalf("unexpected request to %s", r.URL.Path)
+ }
+ }))
+ defer srv.Close()
+
+ client := NewClient(srv.URL, "test-key", true)
+ d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "dev-alice",
+ "key": "sk-abc123",
+ "is_active": false,
+ })
+
+ err := resourceLiteLLMJWTKeyMappingCreate(d, client)
+ if err == nil {
+ t.Fatal("expected the failed deactivation to surface as an error")
+ }
+ if !strings.Contains(err.Error(), "deleted instead") {
+ t.Fatalf("expected the error to explain the mapping was deleted, got %v", err)
+ }
+
+ deleteCalls := 0
+ for _, c := range calls {
+ if c.Path == "/jwt/key/mapping/delete" {
+ deleteCalls++
+ if c.Body["id"] != "map-abc-123" {
+ t.Fatalf("delete must target the mapping that could not be deactivated, got %v", c.Body["id"])
+ }
+ }
+ }
+ if deleteCalls != 1 {
+ t.Fatalf("expected exactly one cleanup delete call, got %d", deleteCalls)
+ }
+
+ if d.Id() != "" {
+ t.Fatalf("a successfully deleted mapping must not remain in state, got id %q", d.Id())
+ }
+}
+
+func TestJWTKeyMappingCreateReportsWhenDeactivationAndDeleteBothFail(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/jwt/key/mapping/new":
+ _ = json.NewEncoder(w).Encode(jwtKeyMappingFixture())
+ default:
+ w.WriteHeader(http.StatusInternalServerError)
+ _ = json.NewEncoder(w).Encode(map[string]string{"detail": "proxy unavailable"})
+ }
+ }))
+ defer srv.Close()
+
+ client := NewClient(srv.URL, "test-key", true)
+ d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "dev-alice",
+ "key": "sk-abc123",
+ "is_active": false,
+ })
+
+ err := resourceLiteLLMJWTKeyMappingCreate(d, client)
+ if err == nil {
+ t.Fatal("expected an error when both deactivation and the cleanup delete fail")
+ }
+ if !strings.Contains(err.Error(), "remove it manually") {
+ t.Fatalf("expected the error to demand manual cleanup, got %v", err)
+ }
+
+ // The mapping is still active on the proxy since neither call succeeded, so
+ // the id must stay in state: the next apply taints and retries the delete,
+ // rather than Terraform losing track of a live, active mapping entirely.
+ if d.Id() != "map-abc-123" {
+ t.Fatalf("expected the id to remain in state so a retry can find it, got %q", d.Id())
+ }
+}
+
+func TestJWTKeyMappingUpdateRevertsDescriptionAndIsActiveWhenTheRecoveryReadAlsoFails(t *testing.T) {
+ // Regression test: on a failed update, only `key` was being reverted
+ // before Read ran. If Read itself then failed too (network blip, proxy
+ // hiccup), description/is_active kept the rejected, never-applied values,
+ // and Terraform could persist them as if the update had succeeded.
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/jwt/key/mapping/update":
+ w.WriteHeader(http.StatusBadRequest)
+ _ = json.NewEncoder(w).Encode(map[string]string{"detail": "rejected"})
+ case "/jwt/key/mapping/info":
+ w.WriteHeader(http.StatusInternalServerError)
+ _ = json.NewEncoder(w).Encode(map[string]string{"detail": "proxy unavailable"})
+ default:
+ t.Fatalf("unexpected request to %s", r.URL.Path)
+ }
+ }))
+ defer srv.Close()
+
+ client := NewClient(srv.URL, "test-key", true)
+
+ d := resourceDataWithChange(t,
+ map[string]string{
+ "id": "map-abc-123",
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "dev-alice",
+ "key": "sk-old-key-0000000000",
+ "description": "old description",
+ "is_active": "true",
+ },
+ map[string]interface{}{
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "dev-alice",
+ "key": "sk-old-key-0000000000",
+ "description": "attempted new description",
+ "is_active": false,
+ },
+ )
+ d.SetId("map-abc-123")
+
+ err := resourceLiteLLMJWTKeyMappingUpdate(d, client)
+ if err == nil {
+ t.Fatal("expected the update failure to surface as an error")
+ }
+ if !strings.Contains(err.Error(), "failed to refresh state afterward") {
+ t.Fatalf("expected the error to mention the failed recovery read, got %v", err)
+ }
+
+ if d.Get("description").(string) != "old description" {
+ t.Fatalf("a rejected description must not survive when the recovery read also fails, got %q", d.Get("description").(string))
+ }
+ if d.Get("is_active").(bool) != true {
+ t.Fatalf("a rejected is_active must not survive when the recovery read also fails, got %v", d.Get("is_active").(bool))
+ }
+}
+
+func TestJWTKeyMappingReadPopulatesStateAndKeepsKey(t *testing.T) {
+ srv, calls := jwtKeyMappingTestServer(t, jwtKeyMappingFixture())
+ defer srv.Close()
+
+ client := NewClient(srv.URL, "test-key", true)
+ d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "dev-alice",
+ "key": "sk-configured-value",
+ })
+ d.SetId("map-abc-123")
+
+ if err := resourceLiteLLMJWTKeyMappingRead(d, client); err != nil {
+ t.Fatalf("read failed: %v", err)
+ }
+
+ read := (*calls)[0]
+ if read.Method != "GET" || read.Path != "/jwt/key/mapping/info" {
+ t.Fatalf("expected GET /jwt/key/mapping/info, got %s %s", read.Method, read.Path)
+ }
+ if read.Query != "id=map-abc-123" {
+ t.Fatalf("expected the mapping id in the query, got %q", read.Query)
+ }
+
+ if d.Get("jwt_claim_value").(string) != "dev-alice" {
+ t.Fatalf("claim value not populated: %q", d.Get("jwt_claim_value").(string))
+ }
+ if d.Get("description").(string) != "dev-alice" {
+ t.Fatalf("description not populated: %q", d.Get("description").(string))
+ }
+ if !d.Get("is_active").(bool) {
+ t.Fatal("is_active not populated")
+ }
+ if d.Get("created_at").(string) != "2026-08-06T10:00:00Z" || d.Get("created_by").(string) != "admin" {
+ t.Fatalf("computed audit fields not populated: %v", d.State().Attributes)
+ }
+ if d.Get("key").(string) != "sk-configured-value" {
+ t.Fatalf("the API never returns the key, so the configured value must survive a read, got %q", d.Get("key").(string))
+ }
+}
+
+func TestJWTKeyMappingReadClearsIDWhenMappingIsGone(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNotFound)
+ _ = json.NewEncoder(w).Encode(map[string]string{"detail": "Mapping not found"})
+ }))
+ defer srv.Close()
+
+ client := NewClient(srv.URL, "test-key", true)
+ d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "dev-alice",
+ "key": "sk-abc123",
+ })
+ d.SetId("map-gone")
+
+ if err := resourceLiteLLMJWTKeyMappingRead(d, client); err != nil {
+ t.Fatalf("a deleted mapping must not fail the read: %v", err)
+ }
+ if d.Id() != "" {
+ t.Fatalf("expected the id to be cleared so Terraform plans a recreate, got %q", d.Id())
+ }
+}
+
+func TestJWTKeyMappingUpdateClearsDescriptionAndSendsKey(t *testing.T) {
+ mapping := jwtKeyMappingFixture()
+ mapping.Description = ""
+ srv, calls := jwtKeyMappingTestServer(t, mapping)
+ defer srv.Close()
+
+ client := NewClient(srv.URL, "test-key", true)
+ d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "dev-alice",
+ "key": "sk-rotated",
+ "is_active": true,
+ })
+ d.SetId("map-abc-123")
+
+ if err := resourceLiteLLMJWTKeyMappingUpdate(d, client); err != nil {
+ t.Fatalf("update failed: %v", err)
+ }
+
+ update := (*calls)[0]
+ if update.Method != "POST" || update.Path != "/jwt/key/mapping/update" {
+ t.Fatalf("expected POST /jwt/key/mapping/update, got %s %s", update.Method, update.Path)
+ }
+ if update.Body["id"] != "map-abc-123" {
+ t.Fatalf("update must carry the mapping id, got %v", update.Body["id"])
+ }
+ if update.Body["key"] != "sk-rotated" {
+ t.Fatalf("rotated key not sent: %v", update.Body["key"])
+ }
+ description, sent := update.Body["description"]
+ if !sent || description != "" {
+ t.Fatalf("a dropped description must be sent as an empty string, since the proxy ignores absent fields: %v", update.Body)
+ }
+ if d.Get("description").(string) != "" {
+ t.Fatalf("description should be cleared in state, got %q", d.Get("description").(string))
+ }
+}
+
+func TestJWTKeyMappingUpdateRevertsKeyOnFailureAndResyncsRest(t *testing.T) {
+ // Regression test for a live-verified bug: Terraform's classic SDKv2 CRUD
+ // model persists ResourceData's diff-applied (attempted) values to state
+ // even when the callback returns an error, unless the provider reverts
+ // them explicitly. Confirmed live: a rejected key rotation left the new,
+ // never-applied key in `terraform state pull` while the proxy kept the
+ // old one, so the next plan falsely reported convergence.
+ calls := make([]jwtKeyMappingCall, 0)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body := map[string]interface{}{}
+ if r.Body != nil {
+ _ = json.NewDecoder(r.Body).Decode(&body)
+ }
+ calls = append(calls, jwtKeyMappingCall{Method: r.Method, Path: r.URL.Path, Query: r.URL.RawQuery, Body: body})
+
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/jwt/key/mapping/update":
+ w.WriteHeader(http.StatusBadRequest)
+ _ = json.NewEncoder(w).Encode(map[string]string{
+ "detail": "The provided key does not match an existing virtual key.",
+ })
+ case "/jwt/key/mapping/info":
+ // Server truth: unchanged, since the rejected update above never applied.
+ _ = json.NewEncoder(w).Encode(jwtKeyMappingFixture())
+ default:
+ t.Fatalf("unexpected request to %s", r.URL.Path)
+ }
+ }))
+ defer srv.Close()
+
+ client := NewClient(srv.URL, "test-key", true)
+
+ d := resourceDataWithChange(t,
+ map[string]string{
+ "id": "map-abc-123",
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "dev-alice",
+ "key": "sk-old-key-0000000000",
+ "description": "dev-alice",
+ "is_active": "true",
+ },
+ map[string]interface{}{
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "dev-alice",
+ "key": "sk-rejected-new-key-00",
+ "description": "attempted new description",
+ "is_active": false,
+ },
+ )
+ d.SetId("map-abc-123")
+
+ err := resourceLiteLLMJWTKeyMappingUpdate(d, client)
+ if err == nil {
+ t.Fatal("expected the rejected key to fail the update")
+ }
+ if !strings.Contains(err.Error(), "does not match an existing virtual key") {
+ t.Fatalf("expected the proxy's rejection reason in the error, got %v", err)
+ }
+
+ if d.Get("key").(string) != "sk-old-key-0000000000" {
+ t.Fatalf("a failed update must not persist the rejected key into state, got %q", d.Get("key").(string))
+ }
+ if d.Get("description").(string) != "dev-alice" {
+ t.Fatalf("a failed update must resync description from the server, got %q", d.Get("description").(string))
+ }
+ if d.Get("is_active").(bool) != true {
+ t.Fatalf("a failed update must resync is_active from the server, got %v", d.Get("is_active").(bool))
+ }
+
+ readCalls := 0
+ for _, c := range calls {
+ if c.Path == "/jwt/key/mapping/info" {
+ readCalls++
+ }
+ }
+ if readCalls != 1 {
+ t.Fatalf("expected exactly one read to resync state after the failed update, got %d", readCalls)
+ }
+}
+
+func TestJWTKeyMappingUpdateOmitsMissingKeyRatherThanBlankingIt(t *testing.T) {
+ srv, calls := jwtKeyMappingTestServer(t, jwtKeyMappingFixture())
+ defer srv.Close()
+
+ client := NewClient(srv.URL, "test-key", true)
+ d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "dev-alice",
+ "description": "dev-alice",
+ "is_active": true,
+ })
+ d.SetId("map-abc-123")
+
+ if err := resourceLiteLLMJWTKeyMappingUpdate(d, client); err != nil {
+ t.Fatalf("update failed: %v", err)
+ }
+
+ if _, sent := (*calls)[0].Body["key"]; sent {
+ t.Fatalf("a missing key must be omitted rather than blanking the mapping token: %v", (*calls)[0].Body)
+ }
+}
+
+func TestJWTKeyMappingDeleteToleratesMissingMapping(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNotFound)
+ _ = json.NewEncoder(w).Encode(map[string]string{"detail": "Mapping not found"})
+ }))
+ defer srv.Close()
+
+ client := NewClient(srv.URL, "test-key", true)
+ d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "dev-alice",
+ "key": "sk-abc123",
+ })
+ d.SetId("map-already-gone")
+
+ if err := resourceLiteLLMJWTKeyMappingDelete(d, client); err != nil {
+ t.Fatalf("deleting an already deleted mapping must succeed: %v", err)
+ }
+ if d.Id() != "" {
+ t.Fatalf("expected the id to be cleared after delete, got %q", d.Id())
+ }
+}
+
+func TestJWTKeyMappingCreateSurfacesDuplicateClaimError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusConflict)
+ _ = json.NewEncoder(w).Encode(map[string]string{
+ "detail": "A mapping for claim 'client_id' = 'dev-alice' already exists.",
+ })
+ }))
+ defer srv.Close()
+
+ client := NewClient(srv.URL, "test-key", true)
+ d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "dev-alice",
+ "key": "sk-abc123",
+ "is_active": true,
+ })
+
+ err := resourceLiteLLMJWTKeyMappingCreate(d, client)
+ if err == nil {
+ t.Fatal("expected a duplicate claim pair to fail")
+ }
+ if !strings.Contains(err.Error(), "already exists") {
+ t.Fatalf("the proxy explanation must reach the user, got %v", err)
+ }
+ if d.Id() != "" {
+ t.Fatalf("no id should be recorded for a failed create, got %q", d.Id())
+ }
+}
+
+func TestJWTKeyMappingCreateDoesNotLeakKeyInErrors(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ _ = json.NewEncoder(w).Encode(map[string]string{
+ "key": "sk-super-secret",
+ "detail": "The provided key does not match an existing virtual key.",
+ })
+ }))
+ defer srv.Close()
+
+ client := NewClient(srv.URL, "test-key", true)
+ d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
+ "jwt_claim_name": "client_id",
+ "jwt_claim_value": "dev-alice",
+ "key": "sk-super-secret",
+ "is_active": true,
+ })
+
+ err := resourceLiteLLMJWTKeyMappingCreate(d, client)
+ if err == nil {
+ t.Fatal("expected an unknown virtual key to fail")
+ }
+ if !strings.Contains(err.Error(), "does not match an existing virtual key") {
+ t.Fatalf("the proxy explanation must reach the user, got %v", err)
+ }
+ if strings.Contains(err.Error(), "sk-super-secret") {
+ t.Fatalf("the virtual key must be redacted in errors, got %v", err)
+ }
+}
diff --git a/terraform/provider/litellm/types.go b/terraform/provider/litellm/types.go
index ee6420732d5..7bef44409fd 100644
--- a/terraform/provider/litellm/types.go
+++ b/terraform/provider/litellm/types.go
@@ -272,3 +272,33 @@ type VectorStoreDeleteRequest struct {
type VectorStoreInfoRequest struct {
VectorStoreID string `json:"vector_store_id"`
}
+
+type JWTKeyMappingRequest struct {
+ JWTClaimName string `json:"jwt_claim_name"`
+ JWTClaimValue string `json:"jwt_claim_value"`
+ Key string `json:"key"`
+ Description string `json:"description,omitempty"`
+}
+
+type JWTKeyMappingUpdateRequest struct {
+ ID string `json:"id"`
+ Key string `json:"key,omitempty"`
+ Description string `json:"description"`
+ IsActive bool `json:"is_active"`
+}
+
+type JWTKeyMappingDeleteRequest struct {
+ ID string `json:"id"`
+}
+
+type JWTKeyMappingResponse struct {
+ ID string `json:"id"`
+ JWTClaimName string `json:"jwt_claim_name"`
+ JWTClaimValue string `json:"jwt_claim_value"`
+ Description string `json:"description,omitempty"`
+ IsActive bool `json:"is_active"`
+ CreatedAt string `json:"created_at,omitempty"`
+ UpdatedAt string `json:"updated_at,omitempty"`
+ CreatedBy string `json:"created_by,omitempty"`
+ UpdatedBy string `json:"updated_by,omitempty"`
+}
diff --git a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
index 60c1fa7b6fb..726e09f3162 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
@@ -2385,6 +2385,7 @@ async def test_start_shadow_eval_seeds_a_zero_funnel_row_per_leg(monkeypatch: py
separates 'nothing was skipped' from a job predating the funnel."""
import litellm.proxy.proxy_server as proxy_server
+ _configure_anthropic_sdk_judge(monkeypatch)
prisma = _shadow_prisma(legs=[])
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
monkeypatch.setattr(proxy_server, "llm_router", _shadow_router())