Skip to content

Commit b1bc9f0

Browse files
authored
Merge pull request #58 from Portkey-AI/feat/exclude-list-policy-condition
Allow excludes in policy limits resources
2 parents e9fe791 + bc14b7e commit b1bc9f0

9 files changed

Lines changed: 193 additions & 79 deletions

docs/resources/rate_limits_policy.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Manages a Portkey rate limits policy. Controls the rate of requests or tokens co
1717

1818
### Required
1919

20-
- `conditions` (String) JSON array of conditions that define which requests the policy applies to. Each condition has 'key' and 'value'.
20+
- `conditions` (String) JSON array of conditions that define which requests the policy applies to. Each condition has 'key', 'value' (string or array of strings), and an optional 'excludes' (string or array of strings).
2121
- `group_by` (String) JSON array of group by fields that define how rate limiting is applied. Each item has 'key'.
2222
- `type` (String) Policy type: 'requests' or 'tokens'.
2323
- `unit` (String) Rate unit: 'rpm' (per minute), 'rph' (per hour), or 'rpd' (per day).

docs/resources/usage_limits_policy.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Manages a Portkey usage limits policy. Controls total usage (cost or tokens) ove
1717

1818
### Required
1919

20-
- `conditions` (String) JSON array of conditions that define which requests the policy applies to. Each condition has 'key' and 'value'.
20+
- `conditions` (String) JSON array of conditions that define which requests the policy applies to. Each condition has 'key', 'value' (string or array of strings), and an optional 'excludes' (string or array of strings).
2121
- `credit_limit` (Number) Maximum usage allowed.
2222
- `group_by` (String) JSON array of group by fields that define how usage is aggregated. Each item has 'key'.
2323
- `type` (String) Policy type: 'cost' or 'tokens'.

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ require (
3434
github.com/hashicorp/logutils v1.0.0 // indirect
3535
github.com/hashicorp/terraform-exec v0.24.0 // indirect
3636
github.com/hashicorp/terraform-json v0.27.2 // indirect
37+
github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0 // indirect
3738
github.com/hashicorp/terraform-plugin-sdk/v2 v2.38.1 // indirect
3839
github.com/hashicorp/terraform-registry-address v0.4.0 // indirect
3940
github.com/hashicorp/terraform-svchost v0.1.1 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ github.com/hashicorp/terraform-json v0.27.2 h1:BwGuzM6iUPqf9JYM/Z4AF1OJ5VVJEEzoK
8383
github.com/hashicorp/terraform-json v0.27.2/go.mod h1:GzPLJ1PLdUG5xL6xn1OXWIjteQRT2CNT9o/6A9mi9hE=
8484
github.com/hashicorp/terraform-plugin-framework v1.17.0 h1:JdX50CFrYcYFY31gkmitAEAzLKoBgsK+iaJjDC8OexY=
8585
github.com/hashicorp/terraform-plugin-framework v1.17.0/go.mod h1:4OUXKdHNosX+ys6rLgVlgklfxN3WHR5VHSOABeS/BM0=
86+
github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0 h1:SJXL5FfJJm17554Kpt9jFXngdM6fXbnUnZ6iT2IeiYA=
87+
github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0/go.mod h1:p0phD0IYhsu9bR4+6OetVvvH59I6LwjXGnTVEr8ox6E=
8688
github.com/hashicorp/terraform-plugin-framework-validators v0.19.0 h1:Zz3iGgzxe/1XBkooZCewS0nJAaCFPFPHdNJd8FgE4Ow=
8789
github.com/hashicorp/terraform-plugin-framework-validators v0.19.0/go.mod h1:GBKTNGbGVJohU03dZ7U8wHqc2zYnMUawgCN+gC0itLc=
8890
github.com/hashicorp/terraform-plugin-go v0.29.0 h1:1nXKl/nSpaYIUBU1IG/EsDOX0vv+9JxAltQyDMpq5mU=

internal/client/client.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1852,10 +1852,13 @@ func (c *Client) DeleteGuardrail(ctx context.Context, slugOrID string) error {
18521852
return err
18531853
}
18541854

1855-
// PolicyCondition represents a condition in a policy
1855+
// PolicyCondition represents a condition in a policy.
1856+
// Value and Excludes use json.RawMessage because the API accepts both
1857+
// a single string (e.g. "key1") and an array of strings (e.g. ["key1","key2"]).
18561858
type PolicyCondition struct {
1857-
Key string `json:"key"`
1858-
Value string `json:"value"`
1859+
Key string `json:"key"`
1860+
Value json.RawMessage `json:"value"`
1861+
Excludes json.RawMessage `json:"excludes,omitempty"`
18591862
}
18601863

18611864
// PolicyGroupBy represents a group by field in a policy

internal/provider/rate_limits_policy_resource.go

Lines changed: 27 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77

8+
"github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes"
89
"github.com/hashicorp/terraform-plugin-framework/path"
910
"github.com/hashicorp/terraform-plugin-framework/resource"
1011
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
@@ -33,17 +34,17 @@ type rateLimitsPolicyResource struct {
3334

3435
// rateLimitsPolicyResourceModel maps the resource schema data.
3536
type rateLimitsPolicyResourceModel struct {
36-
ID types.String `tfsdk:"id"`
37-
Name types.String `tfsdk:"name"`
38-
WorkspaceID types.String `tfsdk:"workspace_id"`
39-
Conditions types.String `tfsdk:"conditions"`
40-
GroupBy types.String `tfsdk:"group_by"`
41-
Type types.String `tfsdk:"type"`
42-
Unit types.String `tfsdk:"unit"`
43-
Value types.Float64 `tfsdk:"value"`
44-
Status types.String `tfsdk:"status"`
45-
CreatedAt types.String `tfsdk:"created_at"`
46-
UpdatedAt types.String `tfsdk:"updated_at"`
37+
ID types.String `tfsdk:"id"`
38+
Name types.String `tfsdk:"name"`
39+
WorkspaceID types.String `tfsdk:"workspace_id"`
40+
Conditions jsontypes.Normalized `tfsdk:"conditions"`
41+
GroupBy jsontypes.Normalized `tfsdk:"group_by"`
42+
Type types.String `tfsdk:"type"`
43+
Unit types.String `tfsdk:"unit"`
44+
Value types.Float64 `tfsdk:"value"`
45+
Status types.String `tfsdk:"status"`
46+
CreatedAt types.String `tfsdk:"created_at"`
47+
UpdatedAt types.String `tfsdk:"updated_at"`
4748
}
4849

4950
// Metadata returns the resource type name.
@@ -75,13 +76,15 @@ func (r *rateLimitsPolicyResource) Schema(_ context.Context, _ resource.SchemaRe
7576
},
7677
},
7778
"conditions": schema.StringAttribute{
78-
Description: "JSON array of conditions that define which requests the policy applies to. Each condition has 'key' and 'value'.",
79+
CustomType: jsontypes.NormalizedType{},
80+
Description: "JSON array of conditions that define which requests the policy applies to. Each condition has 'key', 'value' (string or array of strings), and an optional 'excludes' (string or array of strings).",
7981
Required: true,
8082
PlanModifiers: []planmodifier.String{
8183
stringplanmodifier.RequiresReplace(),
8284
},
8385
},
8486
"group_by": schema.StringAttribute{
87+
CustomType: jsontypes.NormalizedType{},
8588
Description: "JSON array of group by fields that define how rate limiting is applied. Each item has 'key'.",
8689
Required: true,
8790
PlanModifiers: []planmodifier.String{
@@ -199,9 +202,17 @@ func (r *rateLimitsPolicyResource) Create(ctx context.Context, req resource.Crea
199202
return
200203
}
201204

205+
// Preserve plan values for RequiresReplace JSON attributes so Terraform's
206+
// post-apply consistency check doesn't fail due to key ordering differences.
207+
planConditions := plan.Conditions
208+
planGroupBy := plan.GroupBy
209+
202210
// Map response body to schema
203211
r.mapPolicyToState(&plan, policy, false)
204212

213+
plan.Conditions = planConditions
214+
plan.GroupBy = planGroupBy
215+
205216
// Set state to fully populated data
206217
diags = resp.State.Set(ctx, plan)
207218
resp.Diagnostics.Append(diags...)
@@ -230,16 +241,8 @@ func (r *rateLimitsPolicyResource) Read(ctx context.Context, req resource.ReadRe
230241
return
231242
}
232243

233-
// Preserve user's JSON formatting
234-
oldConditions := state.Conditions
235-
oldGroupBy := state.GroupBy
236-
237244
r.mapPolicyToState(&state, policy, true)
238245

239-
// Keep original formatting if semantically equal
240-
state.Conditions = preserveJSONFormatting(oldConditions.ValueString(), state.Conditions.ValueString())
241-
state.GroupBy = preserveJSONFormatting(oldGroupBy.ValueString(), state.GroupBy.ValueString())
242-
243246
// Set refreshed state
244247
diags = resp.State.Set(ctx, &state)
245248
resp.Diagnostics.Append(diags...)
@@ -344,22 +347,18 @@ func (r *rateLimitsPolicyResource) mapPolicyToState(state *rateLimitsPolicyResou
344347
state.Value = types.Float64Value(policy.Value)
345348
state.Status = types.StringValue(policy.Status)
346349

347-
// Convert conditions to JSON string - preserve from state if set (RequiresReplace)
348350
if !preserveRequiresReplace || state.Conditions.IsNull() || state.Conditions.IsUnknown() {
349351
if policy.Conditions != nil {
350-
conditionsBytes, err := json.Marshal(policy.Conditions)
351-
if err == nil {
352-
state.Conditions = types.StringValue(string(conditionsBytes))
352+
if s, err := canonicalJSON(policy.Conditions); err == nil {
353+
state.Conditions = jsontypes.NewNormalizedValue(s)
353354
}
354355
}
355356
}
356357

357-
// Convert group_by to JSON string - preserve from state if set (RequiresReplace)
358358
if !preserveRequiresReplace || state.GroupBy.IsNull() || state.GroupBy.IsUnknown() {
359359
if policy.GroupBy != nil {
360-
groupByBytes, err := json.Marshal(policy.GroupBy)
361-
if err == nil {
362-
state.GroupBy = types.StringValue(string(groupByBytes))
360+
if s, err := canonicalJSON(policy.GroupBy); err == nil {
361+
state.GroupBy = jsontypes.NewNormalizedValue(s)
363362
}
364363
}
365364
}

internal/provider/rate_limits_policy_resource_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,39 @@ func TestAccRateLimitsPolicyResource_updateName(t *testing.T) {
7272
})
7373
}
7474

75+
func TestAccRateLimitsPolicyResource_excludes(t *testing.T) {
76+
rName := acctest.RandomWithPrefix("tf-acc-excludes")
77+
workspaceID := getTestWorkspaceID()
78+
79+
resource.Test(t, resource.TestCase{
80+
PreCheck: func() { testAccPreCheck(t) },
81+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
82+
Steps: []resource.TestStep{
83+
// Create with excludes in conditions
84+
{
85+
Config: testAccRateLimitsPolicyResourceConfigWithExcludes(rName, workspaceID),
86+
Check: resource.ComposeAggregateTestCheckFunc(
87+
resource.TestCheckResourceAttrSet("portkey_rate_limits_policy.test_excludes", "id"),
88+
resource.TestCheckResourceAttr("portkey_rate_limits_policy.test_excludes", "name", rName),
89+
resource.TestCheckResourceAttr("portkey_rate_limits_policy.test_excludes", "type", "requests"),
90+
resource.TestCheckResourceAttr("portkey_rate_limits_policy.test_excludes", "unit", "rpm"),
91+
resource.TestCheckResourceAttr("portkey_rate_limits_policy.test_excludes", "status", "active"),
92+
resource.TestCheckResourceAttr("portkey_rate_limits_policy.test_excludes", "conditions", "[{\"excludes\":[\"gpt-4-mini\",\"gpt-4o-mini\"],\"key\":\"model\",\"value\":[\"gpt-4\",\"gpt-4o\"]}]"),
93+
resource.TestCheckResourceAttr("portkey_rate_limits_policy.test_excludes", "group_by", "[{\"key\":\"api_key\"}]"),
94+
),
95+
},
96+
// ImportState testing — conditions (including excludes) should survive round-trip
97+
{
98+
ResourceName: "portkey_rate_limits_policy.test_excludes",
99+
ImportState: true,
100+
ImportStateVerify: true,
101+
ImportStateVerifyIgnore: []string{"created_at", "updated_at"},
102+
},
103+
// Delete testing automatically occurs in TestCase
104+
},
105+
})
106+
}
107+
75108
func testAccRateLimitsPolicyResourceConfig(name, workspaceID string, value int) string {
76109
return fmt.Sprintf(`
77110
provider "portkey" {}
@@ -96,3 +129,27 @@ resource "portkey_rate_limits_policy" "test" {
96129
}
97130
`, name, workspaceID, value)
98131
}
132+
133+
func testAccRateLimitsPolicyResourceConfigWithExcludes(name, workspaceID string) string {
134+
return fmt.Sprintf(`
135+
provider "portkey" {}
136+
137+
resource "portkey_rate_limits_policy" "test_excludes" {
138+
name = %[1]q
139+
workspace_id = %[2]q
140+
conditions = jsonencode([
141+
{
142+
key = "model"
143+
value = ["gpt-4", "gpt-4o"]
144+
excludes = ["gpt-4-mini", "gpt-4o-mini"]
145+
}
146+
])
147+
group_by = jsonencode([
148+
{ key = "api_key" }
149+
])
150+
type = "requests"
151+
unit = "rpm"
152+
value = 50
153+
}
154+
`, name, workspaceID)
155+
}

internal/provider/usage_limits_policy_resource.go

Lines changed: 41 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77

8+
"github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes"
89
"github.com/hashicorp/terraform-plugin-framework/path"
910
"github.com/hashicorp/terraform-plugin-framework/resource"
1011
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
@@ -33,18 +34,18 @@ type usageLimitsPolicyResource struct {
3334

3435
// usageLimitsPolicyResourceModel maps the resource schema data.
3536
type usageLimitsPolicyResourceModel struct {
36-
ID types.String `tfsdk:"id"`
37-
Name types.String `tfsdk:"name"`
38-
WorkspaceID types.String `tfsdk:"workspace_id"`
39-
Conditions types.String `tfsdk:"conditions"`
40-
GroupBy types.String `tfsdk:"group_by"`
41-
Type types.String `tfsdk:"type"`
42-
CreditLimit types.Float64 `tfsdk:"credit_limit"`
43-
AlertThreshold types.Float64 `tfsdk:"alert_threshold"`
44-
PeriodicReset types.String `tfsdk:"periodic_reset"`
45-
Status types.String `tfsdk:"status"`
46-
CreatedAt types.String `tfsdk:"created_at"`
47-
UpdatedAt types.String `tfsdk:"updated_at"`
37+
ID types.String `tfsdk:"id"`
38+
Name types.String `tfsdk:"name"`
39+
WorkspaceID types.String `tfsdk:"workspace_id"`
40+
Conditions jsontypes.Normalized `tfsdk:"conditions"`
41+
GroupBy jsontypes.Normalized `tfsdk:"group_by"`
42+
Type types.String `tfsdk:"type"`
43+
CreditLimit types.Float64 `tfsdk:"credit_limit"`
44+
AlertThreshold types.Float64 `tfsdk:"alert_threshold"`
45+
PeriodicReset types.String `tfsdk:"periodic_reset"`
46+
Status types.String `tfsdk:"status"`
47+
CreatedAt types.String `tfsdk:"created_at"`
48+
UpdatedAt types.String `tfsdk:"updated_at"`
4849
}
4950

5051
// Metadata returns the resource type name.
@@ -76,13 +77,15 @@ func (r *usageLimitsPolicyResource) Schema(_ context.Context, _ resource.SchemaR
7677
},
7778
},
7879
"conditions": schema.StringAttribute{
79-
Description: "JSON array of conditions that define which requests the policy applies to. Each condition has 'key' and 'value'.",
80+
CustomType: jsontypes.NormalizedType{},
81+
Description: "JSON array of conditions that define which requests the policy applies to. Each condition has 'key', 'value' (string or array of strings), and an optional 'excludes' (string or array of strings).",
8082
Required: true,
8183
PlanModifiers: []planmodifier.String{
8284
stringplanmodifier.RequiresReplace(),
8385
},
8486
},
8587
"group_by": schema.StringAttribute{
88+
CustomType: jsontypes.NormalizedType{},
8689
Description: "JSON array of group by fields that define how usage is aggregated. Each item has 'key'.",
8790
Required: true,
8891
PlanModifiers: []planmodifier.String{
@@ -215,9 +218,17 @@ func (r *usageLimitsPolicyResource) Create(ctx context.Context, req resource.Cre
215218
return
216219
}
217220

221+
// Preserve plan values for RequiresReplace JSON attributes so Terraform's
222+
// post-apply consistency check doesn't fail due to key ordering differences.
223+
planConditions := plan.Conditions
224+
planGroupBy := plan.GroupBy
225+
218226
// Map response body to schema
219227
r.mapPolicyToState(&plan, policy, false)
220228

229+
plan.Conditions = planConditions
230+
plan.GroupBy = planGroupBy
231+
221232
// Set state to fully populated data
222233
diags = resp.State.Set(ctx, plan)
223234
resp.Diagnostics.Append(diags...)
@@ -246,16 +257,8 @@ func (r *usageLimitsPolicyResource) Read(ctx context.Context, req resource.ReadR
246257
return
247258
}
248259

249-
// Preserve user's JSON formatting
250-
oldConditions := state.Conditions
251-
oldGroupBy := state.GroupBy
252-
253260
r.mapPolicyToState(&state, policy, true)
254261

255-
// Keep original formatting if semantically equal
256-
state.Conditions = preserveJSONFormatting(oldConditions.ValueString(), state.Conditions.ValueString())
257-
state.GroupBy = preserveJSONFormatting(oldGroupBy.ValueString(), state.GroupBy.ValueString())
258-
259262
// Set refreshed state
260263
diags = resp.State.Set(ctx, &state)
261264
resp.Diagnostics.Append(diags...)
@@ -377,22 +380,18 @@ func (r *usageLimitsPolicyResource) mapPolicyToState(state *usageLimitsPolicyRes
377380
}
378381
}
379382

380-
// Convert conditions to JSON string - preserve from state if set (RequiresReplace)
381383
if !preserveRequiresReplace || state.Conditions.IsNull() || state.Conditions.IsUnknown() {
382384
if policy.Conditions != nil {
383-
conditionsBytes, err := json.Marshal(policy.Conditions)
384-
if err == nil {
385-
state.Conditions = types.StringValue(string(conditionsBytes))
385+
if s, err := canonicalJSON(policy.Conditions); err == nil {
386+
state.Conditions = jsontypes.NewNormalizedValue(s)
386387
}
387388
}
388389
}
389390

390-
// Convert group_by to JSON string - preserve from state if set (RequiresReplace)
391391
if !preserveRequiresReplace || state.GroupBy.IsNull() || state.GroupBy.IsUnknown() {
392392
if policy.GroupBy != nil {
393-
groupByBytes, err := json.Marshal(policy.GroupBy)
394-
if err == nil {
395-
state.GroupBy = types.StringValue(string(groupByBytes))
393+
if s, err := canonicalJSON(policy.GroupBy); err == nil {
394+
state.GroupBy = jsontypes.NewNormalizedValue(s)
396395
}
397396
}
398397
}
@@ -403,23 +402,19 @@ func (r *usageLimitsPolicyResource) mapPolicyToState(state *usageLimitsPolicyRes
403402
}
404403
}
405404

406-
// preserveJSONFormatting keeps user's JSON format if semantically equal
407-
func preserveJSONFormatting(oldJSON, newJSON string) types.String {
408-
if oldJSON == "" {
409-
return types.StringValue(newJSON)
405+
// canonicalJSON re-encodes v through interface{} so map keys are alphabetically sorted.
406+
func canonicalJSON(v any) (string, error) {
407+
b, err := json.Marshal(v)
408+
if err != nil {
409+
return "", err
410410
}
411-
412-
var oldVal, newVal interface{}
413-
oldErr := json.Unmarshal([]byte(oldJSON), &oldVal)
414-
newErr := json.Unmarshal([]byte(newJSON), &newVal)
415-
416-
if oldErr == nil && newErr == nil {
417-
oldBytes, _ := json.Marshal(oldVal)
418-
newBytes, _ := json.Marshal(newVal)
419-
if string(oldBytes) == string(newBytes) {
420-
return types.StringValue(oldJSON)
421-
}
411+
var normalized any
412+
if err := json.Unmarshal(b, &normalized); err != nil {
413+
return "", err
422414
}
423-
424-
return types.StringValue(newJSON)
415+
out, err := json.Marshal(normalized)
416+
if err != nil {
417+
return "", err
418+
}
419+
return string(out), nil
425420
}

0 commit comments

Comments
 (0)