feat(terraform): expose server_metadata on litellm_key so undeclared metadata is visible (#42453)

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-22 13:22:43 -05:00 committed by GitHub
parent 21c442759e
commit 2ea43214c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 62 additions and 0 deletions

View file

@ -16,6 +16,7 @@ longer signal it.
### Added
- **key**: Computed `server_metadata` attribute on `litellm_key` exposing every metadata entry the proxy stores, so metadata created outside Terraform is visible in state and drift on it shows on refresh, while `metadata` keeps tracking only the declared entries and updates keep preserving undeclared ones
- **team_member_add**: `tpm_limit`, `rpm_limit`, `budget_duration`, and `allowed_models` attributes on `litellm_team_member_add`, applied to every member of the resource; `budget_duration` and `allowed_models` ride on `/team/member_add`, while the limits are sent through `/team/member_update`, which is where the proxy accepts them
- **team**: Optional `team_id` argument on `litellm_team`, so teams can be created with a stable, human-readable ID instead of a provider-generated UUID; changing it forces replacement
- `litellm_jwt_key_mapping` accepts `token_id` as an alternative to `key`, so a

View file

@ -120,6 +120,8 @@ In addition to all arguments above, the following attributes are exported:
* `key` - The generated API key. This is the actual key value that will be used for authentication.
* `server_metadata` - Map of every metadata entry the proxy stores for this key, including entries not declared in `metadata`, so drift on them is visible on refresh. Entries already exposed as their own attributes (`model_rpm_limit`, `model_tpm_limit`, `tags`, `guardrails`, `enforced_params`, `allowed_passthrough_routes`, `rpm_limit_type`, `tpm_limit_type`, `prompts`) are omitted and non-string values are JSON encoded. Terraform never writes it; `metadata` still tracks only the entries declared in the configuration.
* `spend` - The current spend for this key. This reflects the total amount spent using this key so far.
## State Management

View file

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"log"
"slices"
"github.com/hashicorp/go-cty/cty"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
@ -61,6 +62,12 @@ func resourceKey() *schema.Resource {
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"server_metadata": {
Type: schema.TypeMap,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
Description: "Every metadata entry the proxy stores for this key, including ones not declared in metadata. Read-only, so drift on undeclared entries shows up on refresh without Terraform taking ownership of them. Entries the provider already exposes as their own attributes (model_rpm_limit, model_tpm_limit, tags, guardrails, enforced_params, allowed_passthrough_routes, rpm_limit_type, tpm_limit_type, prompts) are omitted, and non-string values are JSON encoded",
},
"tpm_limit": {
Type: schema.TypeInt,
Optional: true,
@ -304,6 +311,7 @@ func resourceKeyRead(ctx context.Context, d *schema.ResourceData, m interface{})
return nil
}
d.Set("server_metadata", serverKeyMetadata(key.Metadata))
key.Metadata = declaredKeyMetadata(key.Metadata, d.Get("metadata").(map[string]interface{}))
mapKeyToResourceData(d, key)
return nil
@ -395,6 +403,25 @@ func declaredKeyMetadata(server, declared map[string]interface{}) map[string]int
return result
}
func serverKeyMetadata(server map[string]interface{}) map[string]string {
result := make(map[string]string, len(server))
for k, v := range server {
if slices.Contains(keyFieldsStoredInMetadata, k) {
continue
}
if s, ok := v.(string); ok {
result[k] = s
continue
}
encoded, err := json.Marshal(v)
if err != nil {
continue
}
result[k] = string(encoded)
}
return result
}
func mergeKeyMetadata(server, oldDeclared, newDeclared map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{}, len(server)+len(newDeclared))
for k, v := range server {

View file

@ -600,6 +600,12 @@ func TestKeyUpdateWithoutMetadataChangePreservesServerMetadata(t *testing.T) {
if got := newState.Attributes["metadata.a"]; got != "1" {
t.Errorf("metadata.a = %q, want 1", got)
}
if got := newState.Attributes["server_metadata.server_side"]; got != "x" {
t.Errorf("server_metadata.server_side = %q, want x", got)
}
if _, present := proxy.updates[0]["server_metadata"]; present {
t.Errorf("computed server_metadata was sent on /key/update: %v", proxy.updates[0]["server_metadata"])
}
}
func TestKeyUpdateWithMetadataChangeMergesOverServerMetadata(t *testing.T) {
@ -654,6 +660,32 @@ func TestKeyReadKeepsOnlyDeclaredMetadata(t *testing.T) {
}
}
func TestKeyReadExposesUndeclaredMetadataInServerMetadata(t *testing.T) {
proxy := &fakeKeyProxy{metadata: map[string]interface{}{
"a": "1",
"server_side": "x",
"model_rpm_limit": map[string]interface{}{"gpt-4o-mini": float64(5)},
"nested": map[string]interface{}{"k": "v"},
}}
srv := httptest.NewServer(proxy.handler())
defer srv.Close()
client := NewClient(srv.URL, "test-key", true)
d := newKeyResourceData(t, map[string]interface{}{"metadata": map[string]interface{}{"a": "1"}})
d.SetId("hash-1")
if diags := resourceKeyRead(context.Background(), d, client); diags.HasError() {
t.Fatalf("Read returned error: %v", diags)
}
if got, want := d.Get("metadata"), map[string]interface{}{"a": "1"}; !reflect.DeepEqual(got, want) {
t.Errorf("metadata in state = %v, want %v", got, want)
}
want := map[string]interface{}{"a": "1", "server_side": "x", "nested": `{"k":"v"}`}
if got := d.Get("server_metadata"); !reflect.DeepEqual(got, want) {
t.Errorf("server_metadata in state = %v, want %v", got, want)
}
}
func TestKeyUpdateSendsChangedDuration(t *testing.T) {
proxy := &fakeKeyProxy{metadata: map[string]interface{}{}}
srv := httptest.NewServer(proxy.handler())