From 25d4dc85be5bdb60cf1d913ade5efea37cb2b3b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20L=C3=B6ffler?= Date: Fri, 11 Sep 2026 09:24:09 +0200 Subject: [PATCH 1/3] fix(terraform): omit empty key_alias from /key/update payload /key/generate leaves key_alias out when it is unset, but UpdateKey built its payload by hand and always sent it, so updating a key that has no alias wrote key_alias: "" to the database. The proxy then treats "" as a real alias and _enforce_unique_key_alias rejects the next aliasless key's update with a 400 saying the alias already exists. Send key_alias only when it is set, matching /key/generate. --- terraform/provider/litellm/client.go | 8 ++++- .../provider/litellm/resource_key_test.go | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/terraform/provider/litellm/client.go b/terraform/provider/litellm/client.go index e68b8a3a80b..566a8b107ff 100644 --- a/terraform/provider/litellm/client.go +++ b/terraform/provider/litellm/client.go @@ -126,13 +126,19 @@ func (c *Client) UpdateKey(key *Key) (*Key, error) { updateData := map[string]interface{}{ "key": key.Key, "team_id": key.TeamID, - "key_alias": key.KeyAlias, "aliases": key.Aliases, "permissions": key.Permissions, "model_max_budget": key.ModelMaxBudget, "blocked": key.Blocked, } + // /key/generate omits an empty key_alias, so sending "" here would store an + // alias the key never had and collide with every other aliasless key on the + // proxy's uniqueness check. + if key.KeyAlias != "" { + updateData["key_alias"] = key.KeyAlias + } + // The proxy keeps the stored metadata only when the field is absent, so nil means omit. if key.Metadata != nil { updateData["metadata"] = key.Metadata diff --git a/terraform/provider/litellm/resource_key_test.go b/terraform/provider/litellm/resource_key_test.go index b6e67360ad0..39e601e0f69 100644 --- a/terraform/provider/litellm/resource_key_test.go +++ b/terraform/provider/litellm/resource_key_test.go @@ -321,6 +321,35 @@ func TestUpdateKeyOmitsEmptyBudgetDuration(t *testing.T) { } } +// /key/generate omits an empty key_alias, so an update that sends "" stores an +// alias the key never had, and the proxy then 400s every other aliasless key on +// its unique-alias check. +func TestUpdateKeyOmitsEmptyKeyAlias(t *testing.T) { + var captured map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &captured) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"key": "sk-test"}`)) + })) + defer srv.Close() + + client := NewClient(srv.URL, "test-key", true) + if _, err := client.UpdateKey(&Key{Key: "sk-test"}); err != nil { + t.Fatalf("UpdateKey returned error: %v", err) + } + if _, present := captured["key_alias"]; present { + t.Errorf("update payload contains empty key_alias: %v", captured["key_alias"]) + } + + if _, err := client.UpdateKey(&Key{Key: "sk-test", KeyAlias: "alias-1"}); err != nil { + t.Fatalf("UpdateKey returned error: %v", err) + } + if captured["key_alias"] != "alias-1" { + t.Errorf("key_alias = %v, want alias-1", captured["key_alias"]) + } +} + func TestResourceKeyUpdateFailureKeepsPriorState(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") From 15a2b0f096136b66e09996c384f2c67177ee3c5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20L=C3=B6ffler?= Date: Mon, 28 Sep 2026 10:10:42 +0200 Subject: [PATCH 2/3] fix(terraform): clear removed key aliases explicitly --- terraform/provider/litellm/client.go | 5 +- terraform/provider/litellm/resource_key.go | 1 + .../provider/litellm/resource_key_test.go | 69 ++++++++++++++++++- terraform/provider/litellm/types.go | 1 + 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/terraform/provider/litellm/client.go b/terraform/provider/litellm/client.go index 566a8b107ff..b0ece5ce231 100644 --- a/terraform/provider/litellm/client.go +++ b/terraform/provider/litellm/client.go @@ -132,11 +132,10 @@ func (c *Client) UpdateKey(key *Key) (*Key, error) { "blocked": key.Blocked, } - // /key/generate omits an empty key_alias, so sending "" here would store an - // alias the key never had and collide with every other aliasless key on the - // proxy's uniqueness check. if key.KeyAlias != "" { updateData["key_alias"] = key.KeyAlias + } else if key.clearKeyAlias { + updateData["key_alias"] = nil } // The proxy keeps the stored metadata only when the field is absent, so nil means omit. diff --git a/terraform/provider/litellm/resource_key.go b/terraform/provider/litellm/resource_key.go index b471cab73e8..8c21d41e38d 100644 --- a/terraform/provider/litellm/resource_key.go +++ b/terraform/provider/litellm/resource_key.go @@ -322,6 +322,7 @@ func resourceKeyUpdate(ctx context.Context, d *schema.ResourceData, m interface{ key := &Key{Key: d.Id()} mapResourceDataToKey(d, key) + key.clearKeyAlias = d.HasChange("key_alias") && key.KeyAlias == "" if !d.HasChange("duration") { key.Duration = "" } diff --git a/terraform/provider/litellm/resource_key_test.go b/terraform/provider/litellm/resource_key_test.go index 39e601e0f69..421ac4d85fc 100644 --- a/terraform/provider/litellm/resource_key_test.go +++ b/terraform/provider/litellm/resource_key_test.go @@ -321,9 +321,6 @@ func TestUpdateKeyOmitsEmptyBudgetDuration(t *testing.T) { } } -// /key/generate omits an empty key_alias, so an update that sends "" stores an -// alias the key never had, and the proxy then 400s every other aliasless key on -// its unique-alias check. func TestUpdateKeyOmitsEmptyKeyAlias(t *testing.T) { var captured map[string]interface{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -350,6 +347,72 @@ func TestUpdateKeyOmitsEmptyKeyAlias(t *testing.T) { } } +func TestResourceKeyAliasChangesConverge(t *testing.T) { + for _, tc := range []struct { + name string + priorAlias string + configuredAlias interface{} + wantAlias string + wantPresent bool + wantNull bool + }{ + {name: "aliasless"}, + {name: "remove", priorAlias: "alias-1", wantPresent: true, wantNull: true}, + {name: "blank", priorAlias: "alias-1", configuredAlias: "", wantPresent: true, wantNull: true}, + {name: "assign", configuredAlias: "alias-1", wantAlias: "alias-1", wantPresent: true}, + {name: "rename", priorAlias: "alias-1", configuredAlias: "alias-2", wantAlias: "alias-2", wantPresent: true}, + {name: "retain", priorAlias: "alias-1", configuredAlias: "alias-1", wantAlias: "alias-1", wantPresent: true}, + } { + t.Run(tc.name, func(t *testing.T) { + storedAlias := tc.priorAlias + updates := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + if req.URL.Path == "/key/update" { + updates++ + var payload map[string]interface{} + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Error(err) + w.WriteHeader(http.StatusBadRequest) + return + } + value, present := payload["key_alias"] + if present != tc.wantPresent || (present && (value == nil) != tc.wantNull) { + t.Errorf("key_alias = %#v, present = %v; want present = %v, null = %v", value, present, tc.wantPresent, tc.wantNull) + } + if present { + storedAlias, _ = value.(string) + } + } + json.NewEncoder(w).Encode(map[string]interface{}{ + "key": "hash-1", + "info": map[string]interface{}{"key_alias": storedAlias, "team_id": "team-1"}, + }) + })) + defer srv.Close() + res := resourceKey() + priorData := newKeyResourceData(t, map[string]interface{}{"key_alias": tc.priorAlias, "team_id": "team-1", "max_budget": 10.0}) + priorData.SetId("hash-1") + config := terraform.NewResourceConfigRaw(map[string]interface{}{"key_alias": tc.configuredAlias, "team_id": "team-1", "max_budget": 25.0}) + diff, err := res.Diff(context.Background(), priorData.State(), config, nil) + if err != nil { + t.Fatal(err) + } + state, diags := res.Apply(context.Background(), priorData.State(), diff, NewClient(srv.URL, "test-key", true)) + if diags.HasError() { + t.Fatalf("apply failed: %v", diags) + } + if updates != 1 || storedAlias != tc.wantAlias || state.Attributes["key_alias"] != tc.wantAlias { + t.Fatalf("updates = %d, stored alias = %q, state alias = %q; want %q", updates, storedAlias, state.Attributes["key_alias"], tc.wantAlias) + } + nextDiff, err := res.Diff(context.Background(), state, config, nil) + if err != nil || (nextDiff != nil && !nextDiff.Empty()) { + t.Fatalf("subsequent plan not clean: diff = %v, error = %v", nextDiff, err) + } + }) + } +} + func TestResourceKeyUpdateFailureKeepsPriorState(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/terraform/provider/litellm/types.go b/terraform/provider/litellm/types.go index a8784b8a6a9..3c01a1e0322 100644 --- a/terraform/provider/litellm/types.go +++ b/terraform/provider/litellm/types.go @@ -129,6 +129,7 @@ type ModelInfo struct { // Key represents a LiteLLM API key. type Key struct { + clearKeyAlias bool Key string `json:"key,omitempty"` TokenID string `json:"token_id,omitempty"` Models []string `json:"models"` From f75725929f13535c4f00ffb99ecc6b15efd74f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20L=C3=B6ffler?= Date: Mon, 28 Sep 2026 10:30:38 +0200 Subject: [PATCH 3/3] test: align interactions compliance checks with current Google schema --- .../unit/interactions/test_openapi_compliance.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/unit/interactions/test_openapi_compliance.py b/tests/unit/interactions/test_openapi_compliance.py index d3f1183cea6..462a9581a7f 100644 --- a/tests/unit/interactions/test_openapi_compliance.py +++ b/tests/unit/interactions/test_openapi_compliance.py @@ -3,12 +3,14 @@ OpenAPI compliance tests for Google Interactions API. Validates that our SDK requests/responses match the OpenAPI spec at: https://ai.google.dev/static/api/interactions.openapi.json +Schema names verified against that spec on 2026-09-28. Run with: pytest tests/unit/interactions/test_openapi_compliance.py -v """ import json import os +import re from typing import Any, Dict from unittest.mock import MagicMock, patch @@ -60,12 +62,11 @@ class TestRequestCompliance: """Tests that our request bodies match the OpenAPI spec.""" def test_create_model_interaction_request_schema(self, spec_dict): - """Verify CreateModelInteractionParams schema fields.""" - schema = spec_dict["components"]["schemas"]["CreateModelInteractionParams"] + """Verify ModelInteraction schema fields.""" + schema = spec_dict["components"]["schemas"]["ModelInteraction"] - # Required fields per spec assert "model" in schema["required"] - assert "input" in schema["required"] + assert "input" in schema["properties"] # Check our supported optional fields exist in spec our_optional_fields = [ @@ -88,7 +89,7 @@ class TestRequestCompliance: def test_input_types_match_spec(self, spec_dict): """Verify input field supports string, Content, Content[], Turn[].""" - schema = spec_dict["components"]["schemas"]["CreateModelInteractionParams"] + schema = spec_dict["components"]["schemas"]["ModelInteraction"] input_schema = schema["properties"]["input"] # The input property may be inline oneOf or a $ref to InteractionsInput @@ -313,7 +314,7 @@ class TestEndpointCompliance: get_path = None for path, methods in paths.items(): - if "{id}" in path and "interactions" in path and "get" in methods: + if re.fullmatch(r".*/interactions/\{[^/{}]+\}", path) and "get" in methods: get_path = path break @@ -326,7 +327,7 @@ class TestEndpointCompliance: delete_path = None for path, methods in paths.items(): - if "{id}" in path and "interactions" in path and "delete" in methods: + if re.fullmatch(r".*/interactions/\{[^/{}]+\}", path) and "delete" in methods: delete_path = path break