Skip to content

Commit a892628

Browse files
author
Nicholas Cecere
committed
fix(provider): resolve optional+computed unknowns after apply
Hydrate optional+computed list/map fields during read for team/model/key resources, resolve nested MCP server tool cost map state, and improve organization member user_id hydration for email-based memberships. Adds regression tests and changelog updates.\n\nRefs #53.
1 parent dd7d9af commit a892628

11 files changed

+961
-4
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.0.3] - 2026-02-09
9+
10+
### Fixed
11+
- **`litellm_team`**: Fixed "Provider returned invalid result object after apply" for omitted optional attributes by fully populating all `Optional + Computed` list/map fields in read state (`models`, `tags`, `guardrails`, `prompts`, `metadata`, `model_aliases`, `model_rpm_limit`, `model_tpm_limit`, `team_member_permissions`) ([#53](https://github.com/ncecere/terraform-provider-litellm/issues/53))
12+
- **`litellm_model`**: Fixed "Provider returned invalid result object after apply" for omitted optional attributes by resolving unknown `access_groups` and `additional_litellm_params` values during readback ([#53](https://github.com/ncecere/terraform-provider-litellm/issues/53))
13+
- **`litellm_key`**: Fixed incomplete readback for `Optional + Computed` fields that could leave unknown values after apply (`models`, `allowed_routes`, `allowed_passthrough_routes`, `allowed_cache_controls`, `guardrails`, `prompts`, `enforced_params`, `tags`, `metadata`, `aliases`, `config`, `permissions`, `model_max_budget`, `model_rpm_limit`, `model_tpm_limit`) and added update readback refresh.
14+
- **`litellm_mcp_server`**: Fixed nested `Optional + Computed` readback for `mcp_info.mcp_server_cost_info.tool_name_to_cost_per_query` so unknown values are resolved.
15+
- **`litellm_organization_member`**: Fixed `user_id` (`Optional + Computed`) hydration when membership is created via `user_email`, by matching on email during reads and persisting the resolved user ID in state.
16+
17+
### Added
18+
- Regression tests for team/model/key/MCP server readback behavior and organization member matching to ensure optional+computed attributes are always known after apply.
19+
820
## [1.0.2] - 2026-02-07
921

1022
### Changed

internal/provider/resource_key.go

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66

7+
"github.com/hashicorp/terraform-plugin-framework/attr"
78
"github.com/hashicorp/terraform-plugin-framework/path"
89
"github.com/hashicorp/terraform-plugin-framework/resource"
910
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
@@ -348,6 +349,10 @@ func (r *KeyResource) Update(ctx context.Context, req resource.UpdateRequest, re
348349
return
349350
}
350351

352+
if err := r.readKey(ctx, &data); err != nil {
353+
resp.Diagnostics.AddWarning("Read Error", fmt.Sprintf("Key updated but failed to read back: %s", err))
354+
}
355+
351356
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
352357
}
353358

@@ -619,6 +624,226 @@ func (r *KeyResource) readKey(ctx context.Context, data *KeyResourceModel) error
619624
if budgetID, ok := result["budget_id"].(string); ok && budgetID != "" {
620625
data.BudgetID = types.StringValue(budgetID)
621626
}
627+
if keyAlias, ok := result["key_alias"].(string); ok && keyAlias != "" {
628+
data.KeyAlias = types.StringValue(keyAlias)
629+
}
630+
if duration, ok := result["duration"].(string); ok && duration != "" {
631+
data.Duration = types.StringValue(duration)
632+
}
633+
if tpmLimitType, ok := result["tpm_limit_type"].(string); ok && tpmLimitType != "" {
634+
data.TPMLimitType = types.StringValue(tpmLimitType)
635+
}
636+
if rpmLimitType, ok := result["rpm_limit_type"].(string); ok && rpmLimitType != "" {
637+
data.RPMLimitType = types.StringValue(rpmLimitType)
638+
}
639+
if budgetDuration, ok := result["budget_duration"].(string); ok && budgetDuration != "" {
640+
data.BudgetDuration = types.StringValue(budgetDuration)
641+
}
642+
if teamID, ok := result["team_id"].(string); ok && teamID != "" {
643+
data.TeamID = types.StringValue(teamID)
644+
}
645+
if userID, ok := result["user_id"].(string); ok && userID != "" {
646+
data.UserID = types.StringValue(userID)
647+
}
648+
if keyValue, ok := result["key"].(string); ok && keyValue != "" {
649+
data.Key = types.StringValue(keyValue)
650+
data.ID = types.StringValue(keyValue)
651+
}
652+
653+
// Handle models list - preserve null when API returns empty and config didn't specify models
654+
if models, ok := result["models"].([]interface{}); ok && len(models) > 0 {
655+
modelsList := make([]attr.Value, 0, len(models))
656+
for _, m := range models {
657+
if str, ok := m.(string); ok {
658+
modelsList = append(modelsList, types.StringValue(str))
659+
}
660+
}
661+
data.Models, _ = types.ListValue(types.StringType, modelsList)
662+
} else if !data.Models.IsNull() {
663+
data.Models, _ = types.ListValue(types.StringType, []attr.Value{})
664+
}
665+
666+
// Handle allowed_routes list - preserve null when API returns empty and config didn't specify allowed_routes
667+
if routes, ok := result["allowed_routes"].([]interface{}); ok && len(routes) > 0 {
668+
routesList := make([]attr.Value, 0, len(routes))
669+
for _, r := range routes {
670+
if str, ok := r.(string); ok {
671+
routesList = append(routesList, types.StringValue(str))
672+
}
673+
}
674+
data.AllowedRoutes, _ = types.ListValue(types.StringType, routesList)
675+
} else if !data.AllowedRoutes.IsNull() {
676+
data.AllowedRoutes, _ = types.ListValue(types.StringType, []attr.Value{})
677+
}
678+
679+
// Handle allowed_passthrough_routes list - preserve null when API returns empty and config didn't specify allowed_passthrough_routes
680+
if routes, ok := result["allowed_passthrough_routes"].([]interface{}); ok && len(routes) > 0 {
681+
routesList := make([]attr.Value, 0, len(routes))
682+
for _, r := range routes {
683+
if str, ok := r.(string); ok {
684+
routesList = append(routesList, types.StringValue(str))
685+
}
686+
}
687+
data.AllowedPassthroughRoutes, _ = types.ListValue(types.StringType, routesList)
688+
} else if !data.AllowedPassthroughRoutes.IsNull() {
689+
data.AllowedPassthroughRoutes, _ = types.ListValue(types.StringType, []attr.Value{})
690+
}
691+
692+
// Handle allowed_cache_controls list - preserve null when API returns empty and config didn't specify allowed_cache_controls
693+
if controls, ok := result["allowed_cache_controls"].([]interface{}); ok && len(controls) > 0 {
694+
controlsList := make([]attr.Value, 0, len(controls))
695+
for _, c := range controls {
696+
if str, ok := c.(string); ok {
697+
controlsList = append(controlsList, types.StringValue(str))
698+
}
699+
}
700+
data.AllowedCacheControls, _ = types.ListValue(types.StringType, controlsList)
701+
} else if !data.AllowedCacheControls.IsNull() {
702+
data.AllowedCacheControls, _ = types.ListValue(types.StringType, []attr.Value{})
703+
}
704+
705+
// Handle guardrails list - preserve null when API returns empty and config didn't specify guardrails
706+
if guardrails, ok := result["guardrails"].([]interface{}); ok && len(guardrails) > 0 {
707+
guardrailsList := make([]attr.Value, 0, len(guardrails))
708+
for _, g := range guardrails {
709+
if str, ok := g.(string); ok {
710+
guardrailsList = append(guardrailsList, types.StringValue(str))
711+
}
712+
}
713+
data.Guardrails, _ = types.ListValue(types.StringType, guardrailsList)
714+
} else if !data.Guardrails.IsNull() {
715+
data.Guardrails, _ = types.ListValue(types.StringType, []attr.Value{})
716+
}
717+
718+
// Handle prompts list - preserve null when API returns empty and config didn't specify prompts
719+
if prompts, ok := result["prompts"].([]interface{}); ok && len(prompts) > 0 {
720+
promptsList := make([]attr.Value, 0, len(prompts))
721+
for _, p := range prompts {
722+
if str, ok := p.(string); ok {
723+
promptsList = append(promptsList, types.StringValue(str))
724+
}
725+
}
726+
data.Prompts, _ = types.ListValue(types.StringType, promptsList)
727+
} else if !data.Prompts.IsNull() {
728+
data.Prompts, _ = types.ListValue(types.StringType, []attr.Value{})
729+
}
730+
731+
// Handle enforced_params list - preserve null when API returns empty and config didn't specify enforced_params
732+
if enforcedParams, ok := result["enforced_params"].([]interface{}); ok && len(enforcedParams) > 0 {
733+
paramsList := make([]attr.Value, 0, len(enforcedParams))
734+
for _, p := range enforcedParams {
735+
if str, ok := p.(string); ok {
736+
paramsList = append(paramsList, types.StringValue(str))
737+
}
738+
}
739+
data.EnforcedParams, _ = types.ListValue(types.StringType, paramsList)
740+
} else if !data.EnforcedParams.IsNull() {
741+
data.EnforcedParams, _ = types.ListValue(types.StringType, []attr.Value{})
742+
}
743+
744+
// Handle tags list - preserve null when API returns empty and config didn't specify tags
745+
if tags, ok := result["tags"].([]interface{}); ok && len(tags) > 0 {
746+
tagsList := make([]attr.Value, 0, len(tags))
747+
for _, t := range tags {
748+
if str, ok := t.(string); ok {
749+
tagsList = append(tagsList, types.StringValue(str))
750+
}
751+
}
752+
data.Tags, _ = types.ListValue(types.StringType, tagsList)
753+
} else if !data.Tags.IsNull() {
754+
data.Tags, _ = types.ListValue(types.StringType, []attr.Value{})
755+
}
756+
757+
// Handle metadata map - preserve null when API returns empty and config didn't specify metadata
758+
if metadata, ok := result["metadata"].(map[string]interface{}); ok && len(metadata) > 0 {
759+
metaMap := make(map[string]attr.Value)
760+
for k, v := range metadata {
761+
if str, ok := v.(string); ok {
762+
metaMap[k] = types.StringValue(str)
763+
}
764+
}
765+
data.Metadata, _ = types.MapValue(types.StringType, metaMap)
766+
} else if !data.Metadata.IsNull() {
767+
data.Metadata, _ = types.MapValue(types.StringType, map[string]attr.Value{})
768+
}
769+
770+
// Handle aliases map - preserve null when API returns empty and config didn't specify aliases
771+
if aliases, ok := result["aliases"].(map[string]interface{}); ok && len(aliases) > 0 {
772+
aliasMap := make(map[string]attr.Value)
773+
for k, v := range aliases {
774+
if str, ok := v.(string); ok {
775+
aliasMap[k] = types.StringValue(str)
776+
}
777+
}
778+
data.Aliases, _ = types.MapValue(types.StringType, aliasMap)
779+
} else if !data.Aliases.IsNull() {
780+
data.Aliases, _ = types.MapValue(types.StringType, map[string]attr.Value{})
781+
}
782+
783+
// Handle config map - preserve null when API returns empty and config didn't specify config
784+
if configMapRaw, ok := result["config"].(map[string]interface{}); ok && len(configMapRaw) > 0 {
785+
configMap := make(map[string]attr.Value)
786+
for k, v := range configMapRaw {
787+
if str, ok := v.(string); ok {
788+
configMap[k] = types.StringValue(str)
789+
}
790+
}
791+
data.Config, _ = types.MapValue(types.StringType, configMap)
792+
} else if !data.Config.IsNull() {
793+
data.Config, _ = types.MapValue(types.StringType, map[string]attr.Value{})
794+
}
795+
796+
// Handle permissions map - preserve null when API returns empty and config didn't specify permissions
797+
if permissions, ok := result["permissions"].(map[string]interface{}); ok && len(permissions) > 0 {
798+
permMap := make(map[string]attr.Value)
799+
for k, v := range permissions {
800+
if str, ok := v.(string); ok {
801+
permMap[k] = types.StringValue(str)
802+
}
803+
}
804+
data.Permissions, _ = types.MapValue(types.StringType, permMap)
805+
} else if !data.Permissions.IsNull() {
806+
data.Permissions, _ = types.MapValue(types.StringType, map[string]attr.Value{})
807+
}
808+
809+
// Handle model_max_budget map - preserve null when API returns empty and config didn't specify model_max_budget
810+
if modelMaxBudget, ok := result["model_max_budget"].(map[string]interface{}); ok && len(modelMaxBudget) > 0 {
811+
budgetMap := make(map[string]attr.Value)
812+
for k, v := range modelMaxBudget {
813+
if num, ok := v.(float64); ok {
814+
budgetMap[k] = types.Float64Value(num)
815+
}
816+
}
817+
data.ModelMaxBudget, _ = types.MapValue(types.Float64Type, budgetMap)
818+
} else if !data.ModelMaxBudget.IsNull() {
819+
data.ModelMaxBudget, _ = types.MapValue(types.Float64Type, map[string]attr.Value{})
820+
}
821+
822+
// Handle model_rpm_limit map - preserve null when API returns empty and config didn't specify model_rpm_limit
823+
if modelRPM, ok := result["model_rpm_limit"].(map[string]interface{}); ok && len(modelRPM) > 0 {
824+
rpmMap := make(map[string]attr.Value)
825+
for k, v := range modelRPM {
826+
if num, ok := v.(float64); ok {
827+
rpmMap[k] = types.Int64Value(int64(num))
828+
}
829+
}
830+
data.ModelRPMLimit, _ = types.MapValue(types.Int64Type, rpmMap)
831+
} else if !data.ModelRPMLimit.IsNull() {
832+
data.ModelRPMLimit, _ = types.MapValue(types.Int64Type, map[string]attr.Value{})
833+
}
834+
835+
// Handle model_tpm_limit map - preserve null when API returns empty and config didn't specify model_tpm_limit
836+
if modelTPM, ok := result["model_tpm_limit"].(map[string]interface{}); ok && len(modelTPM) > 0 {
837+
tpmMap := make(map[string]attr.Value)
838+
for k, v := range modelTPM {
839+
if num, ok := v.(float64); ok {
840+
tpmMap[k] = types.Int64Value(int64(num))
841+
}
842+
}
843+
data.ModelTPMLimit, _ = types.MapValue(types.Int64Type, tpmMap)
844+
} else if !data.ModelTPMLimit.IsNull() {
845+
data.ModelTPMLimit, _ = types.MapValue(types.Int64Type, map[string]attr.Value{})
846+
}
622847

623848
return nil
624849
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package provider
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
10+
"github.com/hashicorp/terraform-plugin-framework/types"
11+
)
12+
13+
func TestReadKeyResolvesUnknownOptionalComputedCollections(t *testing.T) {
14+
t.Parallel()
15+
16+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
17+
w.Header().Set("Content-Type", "application/json")
18+
_ = json.NewEncoder(w).Encode(map[string]interface{}{
19+
"spend": 0.0,
20+
"max_budget": 10.0,
21+
"tpm_limit": 1000.0,
22+
"rpm_limit": 100.0,
23+
"blocked": false,
24+
"organization_id": "org-1",
25+
"max_parallel_requests": 5.0,
26+
})
27+
}))
28+
defer server.Close()
29+
30+
r := &KeyResource{
31+
client: &Client{
32+
APIBase: server.URL,
33+
APIKey: "test-key",
34+
HTTPClient: server.Client(),
35+
},
36+
}
37+
38+
data := KeyResourceModel{
39+
ID: types.StringValue("key-123"),
40+
Key: types.StringValue("key-123"),
41+
Models: types.ListUnknown(types.StringType),
42+
AllowedRoutes: types.ListUnknown(types.StringType),
43+
AllowedPassthroughRoutes: types.ListUnknown(types.StringType),
44+
AllowedCacheControls: types.ListUnknown(types.StringType),
45+
Guardrails: types.ListUnknown(types.StringType),
46+
Prompts: types.ListUnknown(types.StringType),
47+
EnforcedParams: types.ListUnknown(types.StringType),
48+
Tags: types.ListUnknown(types.StringType),
49+
Metadata: types.MapUnknown(types.StringType),
50+
Aliases: types.MapUnknown(types.StringType),
51+
Config: types.MapUnknown(types.StringType),
52+
Permissions: types.MapUnknown(types.StringType),
53+
ModelMaxBudget: types.MapUnknown(types.Float64Type),
54+
ModelRPMLimit: types.MapUnknown(types.Int64Type),
55+
ModelTPMLimit: types.MapUnknown(types.Int64Type),
56+
}
57+
58+
if err := r.readKey(context.Background(), &data); err != nil {
59+
t.Fatalf("readKey returned error: %v", err)
60+
}
61+
62+
if data.Models.IsUnknown() {
63+
t.Fatal("models should be known after read")
64+
}
65+
if data.AllowedRoutes.IsUnknown() {
66+
t.Fatal("allowed_routes should be known after read")
67+
}
68+
if data.AllowedPassthroughRoutes.IsUnknown() {
69+
t.Fatal("allowed_passthrough_routes should be known after read")
70+
}
71+
if data.AllowedCacheControls.IsUnknown() {
72+
t.Fatal("allowed_cache_controls should be known after read")
73+
}
74+
if data.Guardrails.IsUnknown() {
75+
t.Fatal("guardrails should be known after read")
76+
}
77+
if data.Prompts.IsUnknown() {
78+
t.Fatal("prompts should be known after read")
79+
}
80+
if data.EnforcedParams.IsUnknown() {
81+
t.Fatal("enforced_params should be known after read")
82+
}
83+
if data.Tags.IsUnknown() {
84+
t.Fatal("tags should be known after read")
85+
}
86+
if data.Metadata.IsUnknown() {
87+
t.Fatal("metadata should be known after read")
88+
}
89+
if data.Aliases.IsUnknown() {
90+
t.Fatal("aliases should be known after read")
91+
}
92+
if data.Config.IsUnknown() {
93+
t.Fatal("config should be known after read")
94+
}
95+
if data.Permissions.IsUnknown() {
96+
t.Fatal("permissions should be known after read")
97+
}
98+
if data.ModelMaxBudget.IsUnknown() {
99+
t.Fatal("model_max_budget should be known after read")
100+
}
101+
if data.ModelRPMLimit.IsUnknown() {
102+
t.Fatal("model_rpm_limit should be known after read")
103+
}
104+
if data.ModelTPMLimit.IsUnknown() {
105+
t.Fatal("model_tpm_limit should be known after read")
106+
}
107+
}

0 commit comments

Comments
 (0)