Skip to content

fix(terraform): preserve undeclared server-side key metadata on update - #40514

Merged
yassin-berriai merged 1 commit into
litellm_internal_stagingfrom
litellm_key_update_preserve_undeclared_metadata
Sep 10, 2026
Merged

yassin-berriai merged 1 commit into
litellm_internal_stagingfrom
litellm_key_update_preserve_undeclared_metadata

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Any litellm_key update wiped metadata Terraform did not declare
  • UpdateKey always sent metadata, model_rpm_limit, model_tpm_limit; proxy replaces wholesale
  • Server-added metadata (Admin UI, curl, proxy-owned fields) vanished on unrelated changes

How it solves it:

  • Omit metadata and model_*_limit from /key/update when the plan leaves them unchanged
  • When declared metadata changes, merge the diff onto live server metadata, then send
  • On Read, keep only declared keys in state so undeclared ones never show as drift

User Flow

Before: an operator who added metadata to a Terraform-managed key outside Terraform loses it the next time Terraform touches the key

  1. They terraform apply a litellm_key with metadata = { a = "1" } and max_budget = 10
  2. They send POST http://localhost:4000/key/update with {"key": "<token>", "metadata": {"a": "1", "server_side": "x"}} and get 200 with both entries back
  3. They change max_budget = 20 in config and run terraform plan; the plan shows max_budget = 10 -> 20 and also - "server_side" = "x" -> null
  4. They terraform apply; GET http://localhost:4000/key/info?key= returns "metadata": {"a": "1", "model_rpm_limit": {}, "model_tpm_limit": {}}, server_side is gone

After: the same unrelated change leaves the server-side metadata alone

  1. They terraform apply a litellm_key with metadata = { a = "1" } and max_budget = 10
  2. They send POST http://localhost:4000/key/update with {"key": "<token>", "metadata": {"a": "1", "server_side": "x"}} and get 200 with both entries back
  3. They change max_budget = 20 in config and run terraform plan; the plan shows only max_budget = 10 -> 20
  4. They terraform apply; GET http://localhost:4000/key/info?key= returns "metadata": {"a": "1", "server_side": "x"} and a follow-up terraform plan reports no changes
  5. If they later change metadata in config (a = "2", add b = "new"), the apply sets those keys and server_side is still there

Relevant issues

Fixes BerriAI/terraform-provider-litellm#60

Linear ticket

Resolves LIT-6402

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*, make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Root cause in one sentence: resourceKeyUpdate always sent "metadata": <declared map> (plus empty model_rpm_limit / model_tpm_limit) on /key/update, and the proxy's prepare_metadata_fields only keeps stored metadata when the metadata field is absent from the request, so every update replaced the stored JSON with the Terraform-declared subset.

Shared setup: proxy from this checkout (PYTHONPATH=<checkout> uv run --no-sync litellm --config proxy_config.yaml --port 16402 --use_prisma_db_push, Postgres 16 in Docker), Terraform v1.13.3 with a dev_overrides block pointing BerriAI/litellm at the provider binary under test. Same proxy for both arms; only the provider binary differs. Build identity: strings <binary> | grep -c litellm.mergeKeyMetadata prints 0 for the base binary and 1 for the fixed one. main.tf.v1 and main.tf.v2 are sha256-pinned and identical across arms; the only difference between them is max_budget = 10 vs max_budget = 20.

resource "litellm_key" "k" {
  models     = ["fake-openai"]
  key_alias  = "lit6402-alias"
  max_budget = 10
  metadata = {
    a = "1"
  }
}

Both arms hit the same proxy branch on the unrelated update: prepare_metadata_fields with metadata present in the request (Before, wholesale replace) vs metadata absent (After, existing_metadata.copy() kept). The explicit metadata change case in After also hits the metadata present branch, on purpose, with the merged map.

Before (b22ca7a)

  1. cp main.tf.v1 main.tf && terraform apply -auto-approve
    litellm_key.k: Creating...
    litellm_key.k: Creation complete after 0s [id=24bc9077...]
    Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
    
  2. curl -s "http://localhost:16402/key/info?key=$TOKEN" -H "Authorization: Bearer $MASTER"
    {"metadata": {"a": "1"}, "max_budget": 10.0}
    
  3. curl -s -X POST http://localhost:16402/key/update -H "Authorization: Bearer $MASTER" -H "Content-Type: application/json" -d '{"key": "'$TOKEN'", "metadata": {"a": "1", "server_side": "x"}}'
    {"metadata": {"a": "1", "server_side": "x"}}
    
  4. cp main.tf.v2 main.tf && terraform plan
      ~ update in-place
      ~ resource "litellm_key" "k" {
          ~ max_budget       = 10 -> 20
          ~ metadata         = {
              - "server_side" = "x" -> null
            }
    Plan: 0 to add, 1 to change, 0 to destroy.
    
  5. terraform apply -auto-approve
    litellm_key.k: Modifying... [id=24bc9077...]
    litellm_key.k: Modifications complete after 0s [id=24bc9077...]
    Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
    
  6. curl -s "http://localhost:16402/key/info?key=$TOKEN" -H "Authorization: Bearer $MASTER"
    {"metadata": {"a": "1", "model_rpm_limit": {}, "model_tpm_limit": {}}, "max_budget": 20.0}
    
    server_side is gone and two empty proxy-owned maps were written by the provider

After (c119e27)

  1. cp main.tf.v1 main.tf && terraform apply -auto-approve
    litellm_key.k: Creating...
    litellm_key.k: Creation complete after 0s [id=70e43c07...]
    Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
    
  2. curl -s "http://localhost:16402/key/info?key=$TOKEN" -H "Authorization: Bearer $MASTER"
    {"metadata": {"a": "1"}, "max_budget": 10.0}
    
  3. curl -s -X POST http://localhost:16402/key/update -H "Authorization: Bearer $MASTER" -H "Content-Type: application/json" -d '{"key": "'$TOKEN'", "metadata": {"a": "1", "server_side": "x"}}'
    {"metadata": {"a": "1", "server_side": "x"}}
    
  4. cp main.tf.v2 main.tf && terraform plan
      ~ update in-place
      ~ resource "litellm_key" "k" {
          ~ max_budget       = 10 -> 20
    Plan: 0 to add, 1 to change, 0 to destroy.
    
  5. terraform apply -auto-approve
    litellm_key.k: Modifying... [id=70e43c07...]
    litellm_key.k: Modifications complete after 0s [id=70e43c07...]
    Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
    
  6. curl -s "http://localhost:16402/key/info?key=$TOKEN" -H "Authorization: Bearer $MASTER"
    {"metadata": {"a": "1", "server_side": "x"}, "max_budget": 20.0}
    
  7. terraform plan
    No changes. Your infrastructure matches the configuration.
    
  8. Edge case, explicit metadata change: main.tf with a = "2" and b = "new", then terraform plan && terraform apply -auto-approve
          ~ metadata         = {
              ~ "a" = "1" -> "2"
              + "b" = "new"
    Plan: 0 to add, 1 to change, 0 to destroy.
    Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
    
    curl -s "http://localhost:16402/key/info?key=$TOKEN" -H "Authorization: Bearer $MASTER"
    {"metadata": {"a": "2", "b": "new", "server_side": "x"}}
    
  9. Edge case, declared key removed: back to main.tf.v2 (drops b), terraform apply -auto-approve, then the same GET
    {"metadata": {"a": "1", "server_side": "x"}}
    

Provider gates run locally from terraform/provider: gofmt -l . (empty), go vet ./..., go build ./..., go test -timeout 120s ./... (ok), and the endpoint audit against a freshly dumped OpenAPI schema (OK: 136 request call sites verified against 620 proxy OpenAPI paths).

Mutation check: the four new tests in resource_key_test.go drive the real Resource.Diff + Resource.Apply against an httptest proxy that mimics the wholesale replace. On the base client.go / resource_key.go three of them fail (metadata and both model_*_limit sent while unchanged, server_side lost, Read pulls server_side into state); with the fix all pass. Six targeted mutants (drop the HasChange gate, always or never send model limits, skip the Read filter, delta-only merge, merge that forgets removed keys) are each killed by at least one test.

Type

🐛 Bug Fix

Caveats (if any)

Medium

  • Product decision, flagged: chosen behavior is "omit when unchanged, merge onto live server metadata when changed"
    • A key removed from metadata in config is deleted on the server; keys never declared are left alone
    • Undeclared server metadata is not visible in Terraform state (filtered on Read), so terraform plan cannot show it
  • Metadata changes now cost one extra GET /key/info per update to read the live server map
  • Explicit metadata changes are a GET then POST; a metadata edit made in between is overwritten
    • /key/update has no atomic merge or version check, so the provider cannot close this window
    • Before this PR the overwrite happened on every update, using the state from the last refresh

Low

Link to Devin session: https://app.devin.ai/sessions/96b831b532184cee8fd2228f2235e6f2
Open in Devin Desktop: https://app.devin.ai/desktop/session/96b831b532184cee8fd2228f2235e6f2?variant=devin
Requested by: @yassin-berriai

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@greptileai please review

@greptile-apps

greptile-apps Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR updates the Terraform provider to preserve undeclared server-side key metadata during updates.

  • Omits unchanged metadata and per-model limits from /key/update requests.
  • Merges declared metadata changes into current server metadata.
  • Filters undeclared metadata from Terraform state to prevent drift.
  • Adds regression tests for unchanged metadata, explicit metadata changes, model-limit updates, and state filtering.

Confidence Score: 5/5

The PR appears safe to merge, with no outstanding findings or newly introduced issues since the previous review.

The previously identified GET-to-update race was explicitly documented as a provider-side limitation and its thread was resolved; the current changes correctly prevent unrelated Terraform updates from replacing undeclared server metadata, and no additional changes have been made since that review.

Important Files Changed

Filename Overview
terraform/provider/litellm/client.go Conditionally includes metadata and model-limit fields in key update requests so omitted fields retain their server-side values.
terraform/provider/litellm/resource_key.go Tracks changed fields, merges declared metadata changes with live metadata, and limits state to Terraform-declared metadata keys.
terraform/provider/litellm/resource_key_test.go Adds focused integration-style provider tests covering metadata preservation, merging, state filtering, and changed model limits.

Reviews (2): Last reviewed commit: "fix(terraform): preserve undeclared serv..." | Re-trigger Greptile

Comment thread terraform/provider/litellm/resource_key.go
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review c119e27; the race window is documented under Caveats and cannot be closed provider-side.

@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@yassin-berriai
yassin-berriai merged commit b64197c into litellm_internal_staging Sep 10, 2026
83 of 84 checks passed
@yassin-berriai
yassin-berriai deleted the litellm_key_update_preserve_undeclared_metadata branch September 10, 2026 02:44
yassin-berriai added a commit that referenced this pull request Sep 10, 2026
…0527)

The squash of #40512 onto a base that already carried #40514 left the Partial call on the metadata pre-read error path only, so a rejected /key/update again persisted the planned values into state and TestResourceKeyUpdateFailureKeepsPriorState fails on the default branch.

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: any key update silently drops server-side metadata not declared in config

2 participants