Skip to content

Commit 63d0355

Browse files
committed
pivot to using ratelimit overrides from metadata
Signed-off-by: Anurag Aggarwal <kanurag94@gmail.com>
1 parent 47b2d19 commit 63d0355

19 files changed

Lines changed: 473 additions & 335 deletions

api/v1beta1/ai_gateway_route.go

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -206,19 +206,15 @@ type AIGatewayRouteSpec struct {
206206
// +kubebuilder:validation:MaxItems=36
207207
LLMRequestCosts []LLMRequestCost `json:"llmRequestCosts,omitempty"`
208208

209-
// RateLimitsFromHeaders emits per-request rate-limit override structs from request headers
210-
// into io.envoy.ai_gateway dynamic metadata. Each header value must be formatted as
211-
// "<count>/<unit>" (e.g. "100000/HOUR"). Overrides the global defaults from GatewayConfig
212-
// for entries with the same MetadataKey.
213-
//
214-
// See RateLimitFromHeader for the security requirement to strip these headers from client
215-
// requests using ClientTrafficPolicy.spec.headers.earlyRequestHeaders.remove.
209+
// RateLimits defines per-route rate-limit override entries that emit per-request limit structs
210+
// into io.envoy.ai_gateway dynamic metadata. Entries with the same MetadataKey override the
211+
// gateway-level GlobalRateLimits defined in GatewayConfig.
216212
//
217213
// +optional
218214
// +listType=map
219215
// +listMapKey=metadataKey
220216
// +kubebuilder:validation:MaxItems=36
221-
RateLimitsFromHeaders []RateLimitFromHeader `json:"rateLimitsFromHeaders,omitempty"`
217+
RateLimits []RateLimitOverride `json:"rateLimits,omitempty"`
222218
}
223219

224220
// AIGatewayRouteRule is a rule that defines the routing behavior of the AIGatewayRoute.

api/v1beta1/gateway_config.go

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,18 +81,16 @@ type GatewayConfigSpec struct {
8181
// +listMapKey=metadataKey
8282
GlobalLLMRequestCosts []LLMRequestCost `json:"globalLLMRequestCosts,omitempty"`
8383

84-
// GlobalRateLimitsFromHeaders defines gateway-level defaults for emitting per-request
85-
// rate-limit override structs from request headers into io.envoy.ai_gateway dynamic metadata.
86-
// Route-scoped entries with the same MetadataKey take precedence.
87-
//
88-
// See RateLimitFromHeader for the security requirement to strip these headers from client
89-
// requests using ClientTrafficPolicy.spec.headers.earlyRequestHeaders.remove.
84+
// GlobalRateLimits defines gateway-level defaults for emitting per-request rate-limit
85+
// override structs into io.envoy.ai_gateway dynamic metadata. Each entry maps a metadata
86+
// key to a source that supplies the per-request limit value. Route-scoped entries with the
87+
// same MetadataKey take precedence over these global defaults.
9088
//
9189
// +optional
9290
// +listType=map
9391
// +listMapKey=metadataKey
9492
// +kubebuilder:validation:MaxItems=36
95-
GlobalRateLimitsFromHeaders []RateLimitFromHeader `json:"globalRateLimitsFromHeaders,omitempty"`
93+
GlobalRateLimits []RateLimitOverride `json:"globalRateLimits,omitempty"`
9694
}
9795

9896
// GatewayConfigExtProc holds runtime-specific configuration for the external processor.

api/v1beta1/shared_types.go

Lines changed: 40 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -165,43 +165,52 @@ const (
165165
AIGatewayFilterMetadataNamespace = "io.envoy.ai_gateway"
166166
)
167167

168-
// RateLimitFromHeader emits a per-request rate-limit override struct into io.envoy.ai_gateway
169-
// dynamic metadata from a trusted request header. The header value must be formatted as
170-
// "COUNT/UNIT" (e.g. "100000/HOUR"). The gateway parses it and writes a struct with
171-
// requests_per_unit and unit fields under MetadataKey — the struct
172-
// Envoy's RateLimit.Override.DynamicMetadata reads. If the header is absent or malformed the key is omitted.
173-
//
174-
// Security note: this feature reads the rate-limit value directly from an incoming request header.
175-
// A client that can set this header can inflate its own quota. To prevent spoofing, you MUST strip
176-
// the header from client requests before the HTTP filter chain (including ext-authz) processes it.
177-
// Use ClientTrafficPolicy.spec.headers.earlyRequestHeaders.remove on the associated Gateway:
178-
//
179-
// apiVersion: gateway.envoyproxy.io/v1alpha1
180-
// kind: ClientTrafficPolicy
181-
// spec:
182-
// targetRefs:
183-
// - group: gateway.networking.k8s.io
184-
// kind: Gateway
185-
// name: <your-gateway>
186-
// headers:
187-
// earlyRequestHeaders:
188-
// remove: ["<header-name>"]
189-
//
190-
// After stripping, only the trusted ext-authz (or another filter running after the strip)
191-
// can set the header, ensuring the value reflects the actual tenant quota.
192-
type RateLimitFromHeader struct {
193-
// MetadataKey is the key written under io.envoy.ai_gateway in the dynamic metadata.
168+
// RateLimitOverride emits a per-request rate-limit override struct into io.envoy.ai_gateway
169+
// dynamic metadata. The source value must be formatted as "COUNT/UNIT" (e.g. "100000/HOUR").
170+
// The gateway parses it and writes { requests_per_unit, unit } under MetadataKey — the struct
171+
// Envoy's RateLimit.Override.DynamicMetadata reads. If the source value is absent or malformed
172+
// the key is omitted and the BackendTrafficPolicy static default applies.
173+
type RateLimitOverride struct {
174+
// MetadataKey is the key written under io.envoy.ai_gateway in the dynamic metadata,
175+
// referenced by BackendTrafficPolicy.rateLimit.global.rules[].limit.fromMetadata.key.
194176
//
195177
// +kubebuilder:validation:Required
196178
MetadataKey string `json:"metadataKey"`
197-
// Header is the request header whose value encodes the rate limit as "<count>/<unit>",
198-
// where unit is one of SECOND, MINUTE, HOUR, DAY.
179+
// Source defines where the rate-limit value is read from.
180+
// Exactly one field within Source must be set.
181+
//
182+
// +kubebuilder:validation:Required
183+
Source RateLimitOverrideSource `json:"source"`
184+
}
185+
186+
// RateLimitOverrideSource defines the origin of the rate-limit value.
187+
// Exactly one field must be set.
188+
//
189+
// +kubebuilder:validation:XValidation:rule="has(self.fromMetadata)",message="exactly one of fromMetadata must be set"
190+
type RateLimitOverrideSource struct {
191+
// FromMetadata reads the rate-limit value from filter dynamic metadata set by a preceding
192+
// Envoy filter (typically ext_authz). The value at the referenced namespace/key must be a
193+
// string formatted as "<count>/<unit>" (e.g. "100000/HOUR"), where unit is one of
194+
// SECOND, MINUTE, HOUR, DAY.
195+
//
196+
// Because dynamic metadata can only be set by Envoy filters — not by downstream clients —
197+
// this source is safe against client spoofing with no additional configuration required.
199198
//
200-
// This header must be set exclusively by a trusted component (e.g. ext-authz).
201-
// See the security note on RateLimitFromHeader for how to prevent client spoofing.
199+
// +kubebuilder:validation:Required
200+
FromMetadata RateLimitMetadataSource `json:"fromMetadata"`
201+
}
202+
203+
// RateLimitMetadataSource identifies a string field in Envoy filter dynamic metadata.
204+
type RateLimitMetadataSource struct {
205+
// Namespace is the filter metadata namespace to read from.
206+
// For ext_authz this is typically "envoy.filters.http.ext_authz".
207+
//
208+
// +kubebuilder:validation:Required
209+
Namespace string `json:"namespace"`
210+
// Key is the field name within Namespace whose string value encodes the rate limit.
202211
//
203212
// +kubebuilder:validation:Required
204-
Header string `json:"header"`
213+
Key string `json:"key"`
205214
}
206215

207216
// HTTPHeaderMutation defines the mutation of HTTP headers that will be applied to the request

api/v1beta1/zz_generated.deepcopy.go

Lines changed: 20 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/controller/gateway.go

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -138,16 +138,16 @@ func (c *GatewayController) Reconcile(ctx context.Context, req ctrl.Request) (ct
138138
return ctrl.Result{}, err
139139
}
140140
var defaultLLMCosts []aigv1b1.LLMRequestCost
141-
var defaultRateLimitsFromHeaders []aigv1b1.RateLimitFromHeader
141+
var defaultRateLimits []aigv1b1.RateLimitOverride
142142
if gwConfig != nil {
143143
defaultLLMCosts = gwConfig.Spec.GlobalLLMRequestCosts
144-
defaultRateLimitsFromHeaders = gwConfig.Spec.GlobalRateLimitsFromHeaders
144+
defaultRateLimits = gwConfig.Spec.GlobalRateLimits
145145
}
146146

147147
// We need to create the filter config in Envoy Gateway system namespace because the sidecar extproc need
148148
// to access it.
149149
var hasEffectiveRoutes bool // indicates whether the filter config is effective (i.e., there is at least one active route).
150-
hasEffectiveRoutes, err = c.reconcileFilterConfigSecret(ctx, FilterConfigSecretPerGatewayName(gw.Name, gw.Namespace), namespace, aiRoutes.Items, mcpRoutes.Items, uid, defaultLLMCosts, defaultRateLimitsFromHeaders)
150+
hasEffectiveRoutes, err = c.reconcileFilterConfigSecret(ctx, FilterConfigSecretPerGatewayName(gw.Name, gw.Namespace), namespace, aiRoutes.Items, mcpRoutes.Items, uid, defaultLLMCosts, defaultRateLimits)
151151
if err != nil {
152152
return ctrl.Result{}, err
153153
}
@@ -250,12 +250,21 @@ func aigwLLMRequestCostToFilterAPI(cost aigv1b1.LLMRequestCost, routeName string
250250
return out, nil
251251
}
252252

253-
func aigwGlobalRateLimitFromHeaderToFilterAPI(h aigv1b1.RateLimitFromHeader) filterapi.GlobalRateLimitFromHeader {
254-
return filterapi.GlobalRateLimitFromHeader{MetadataKey: h.MetadataKey, Header: strings.ToLower(h.Header)}
253+
func aigwGlobalRateLimitToFilterAPI(h aigv1b1.RateLimitOverride) filterapi.GlobalRateLimitOverride {
254+
return filterapi.GlobalRateLimitOverride{
255+
MetadataKey: h.MetadataKey,
256+
Namespace: h.Source.FromMetadata.Namespace,
257+
Key: h.Source.FromMetadata.Key,
258+
}
255259
}
256260

257-
func aigwRateLimitFromHeaderToFilterAPI(h aigv1b1.RateLimitFromHeader, routeName string) filterapi.RateLimitFromHeader {
258-
return filterapi.RateLimitFromHeader{MetadataKey: h.MetadataKey, Header: strings.ToLower(h.Header), RouteName: routeName}
261+
func aigwRateLimitToFilterAPI(h aigv1b1.RateLimitOverride, routeName string) filterapi.RateLimitOverride {
262+
return filterapi.RateLimitOverride{
263+
MetadataKey: h.MetadataKey,
264+
Namespace: h.Source.FromMetadata.Namespace,
265+
Key: h.Source.FromMetadata.Key,
266+
RouteName: routeName,
267+
}
259268
}
260269

261270
// mergeBodyMutations merges route-level and backend-level BodyMutation with route-level taking precedence.
@@ -361,7 +370,7 @@ func (c *GatewayController) reconcileFilterConfigSecret(
361370
mcpRoutes []aigv1b1.MCPRoute,
362371
uuid string,
363372
defaultLLMCosts []aigv1b1.LLMRequestCost,
364-
defaultRateLimitsFromHeaders []aigv1b1.RateLimitFromHeader,
373+
defaultRateLimits []aigv1b1.RateLimitOverride,
365374
) (hasEffectiveRoute bool, _ error) {
366375
// Precondition: aiGatewayRoutes is not empty as we early return if it is empty.
367376
ec := &filterapi.Config{UUID: uuid, Version: version.Parse()}
@@ -378,10 +387,10 @@ func (c *GatewayController) reconcileFilterConfigSecret(
378387
}
379388
ec.GlobalLLMRequestCosts = append(ec.GlobalLLMRequestCosts, fc)
380389
}
381-
// Process global RateLimitsFromHeaders from GatewayConfig.
390+
// Process global RateLimits from GatewayConfig.
382391
// CRD enforces uniqueness via +listType=map, so no deduplication needed.
383-
for _, h := range defaultRateLimitsFromHeaders {
384-
ec.GlobalRateLimitsFromHeaders = append(ec.GlobalRateLimitsFromHeaders, aigwGlobalRateLimitFromHeaderToFilterAPI(h))
392+
for _, h := range defaultRateLimits {
393+
ec.GlobalRateLimits = append(ec.GlobalRateLimits, aigwGlobalRateLimitToFilterAPI(h))
385394
}
386395

387396
// Models contributed by routes with no Spec.Hostnames. We only promote these to
@@ -523,12 +532,12 @@ func (c *GatewayController) reconcileFilterConfigSecret(
523532
ec.LLMRequestCosts = append(ec.LLMRequestCosts, fc)
524533
}
525534

526-
dedupRL := map[string]filterapi.RateLimitFromHeader{}
527-
for _, h := range aiGatewayRoute.Spec.RateLimitsFromHeaders {
528-
dedupRL[h.MetadataKey] = aigwRateLimitFromHeaderToFilterAPI(h, routeName)
535+
dedupRL := map[string]filterapi.RateLimitOverride{}
536+
for _, h := range aiGatewayRoute.Spec.RateLimits {
537+
dedupRL[h.MetadataKey] = aigwRateLimitToFilterAPI(h, routeName)
529538
}
530539
for _, h := range dedupRL {
531-
ec.RateLimitsFromHeaders = append(ec.RateLimitsFromHeaders, h)
540+
ec.RateLimits = append(ec.RateLimits, h)
532541
}
533542
}
534543
}

internal/controller/gateway_test.go

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -782,9 +782,9 @@ func TestGatewayController_reconcileFilterConfigSecret_SkipsDeletedRoutes(t *tes
782782
require.Contains(t, fc.Backends[0].Name, "apple")
783783
}
784784

785-
// TestGatewayController_reconcileFilterConfigSecret_RateLimitsFromHeaders verifies that
786-
// route-scoped RateLimitsFromHeaders and global RateLimitsFromHeaders are projected into the filter config.
787-
func TestGatewayController_reconcileFilterConfigSecret_RateLimitsFromHeaders(t *testing.T) {
785+
// TestGatewayController_reconcileFilterConfigSecret_RateLimits verifies that
786+
// route-scoped RateLimits and global RateLimits are projected into the filter config.
787+
func TestGatewayController_reconcileFilterConfigSecret_RateLimits(t *testing.T) {
788788
fakeClient := requireNewFakeClientWithIndexes(t)
789789
kube := fake2.NewClientset()
790790
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&zap.Options{Development: true, Level: zapcore.DebugLevel})))
@@ -799,9 +799,9 @@ func TestGatewayController_reconcileFilterConfigSecret_RateLimitsFromHeaders(t *
799799
Rules: []aigv1b1.AIGatewayRouteRule{
800800
{BackendRefs: []aigv1b1.AIGatewayRouteRuleBackendRef{{Name: "some-backend"}}},
801801
},
802-
RateLimitsFromHeaders: []aigv1b1.RateLimitFromHeader{
803-
{MetadataKey: "llm_input_token_limit", Header: "x-aigw-limit-input"},
804-
{MetadataKey: "llm_output_token_limit", Header: "x-aigw-limit-output"},
802+
RateLimits: []aigv1b1.RateLimitOverride{
803+
{MetadataKey: "llm_input_token_limit", Source: aigv1b1.RateLimitOverrideSource{FromMetadata: aigv1b1.RateLimitMetadataSource{Namespace: "my.ext_authz", Key: "input_limit"}}},
804+
{MetadataKey: "llm_output_token_limit", Source: aigv1b1.RateLimitOverrideSource{FromMetadata: aigv1b1.RateLimitMetadataSource{Namespace: "my.ext_authz", Key: "output_limit"}}},
805805
},
806806
},
807807
},
@@ -815,8 +815,8 @@ func TestGatewayController_reconcileFilterConfigSecret_RateLimitsFromHeaders(t *
815815
})
816816
require.NoError(t, err)
817817

818-
globalRateLimits := []aigv1b1.RateLimitFromHeader{
819-
{MetadataKey: "llm_total_token_limit", Header: "x-aigw-limit-total"},
818+
globalRateLimits := []aigv1b1.RateLimitOverride{
819+
{MetadataKey: "llm_total_token_limit", Source: aigv1b1.RateLimitOverrideSource{FromMetadata: aigv1b1.RateLimitMetadataSource{Namespace: "my.ext_authz", Key: "total_limit"}}},
820820
}
821821

822822
const someNamespace = "some-namespace"
@@ -833,15 +833,16 @@ func TestGatewayController_reconcileFilterConfigSecret_RateLimitsFromHeaders(t *
833833
var fc filterapi.Config
834834
require.NoError(t, yaml.Unmarshal([]byte(configStr), &fc))
835835

836-
require.Len(t, fc.GlobalRateLimitsFromHeaders, 1)
837-
require.Equal(t, "llm_total_token_limit", fc.GlobalRateLimitsFromHeaders[0].MetadataKey)
838-
require.Equal(t, "x-aigw-limit-total", fc.GlobalRateLimitsFromHeaders[0].Header)
836+
require.Len(t, fc.GlobalRateLimits, 1)
837+
require.Equal(t, "llm_total_token_limit", fc.GlobalRateLimits[0].MetadataKey)
838+
require.Equal(t, "my.ext_authz", fc.GlobalRateLimits[0].Namespace)
839+
require.Equal(t, "total_limit", fc.GlobalRateLimits[0].Key)
839840

840-
require.Len(t, fc.RateLimitsFromHeaders, 2)
841-
for _, h := range fc.RateLimitsFromHeaders {
841+
require.Len(t, fc.RateLimits, 2)
842+
for _, h := range fc.RateLimits {
842843
require.Equal(t, "ns/tenant-route", h.RouteName)
843844
}
844-
metadataKeys := []string{fc.RateLimitsFromHeaders[0].MetadataKey, fc.RateLimitsFromHeaders[1].MetadataKey}
845+
metadataKeys := []string{fc.RateLimits[0].MetadataKey, fc.RateLimits[1].MetadataKey}
845846
require.ElementsMatch(t, []string{"llm_input_token_limit", "llm_output_token_limit"}, metadataKeys)
846847
}
847848

internal/extproc/models_processor.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
1717
typev3 "github.com/envoyproxy/go-control-plane/envoy/type/v3"
1818
"google.golang.org/grpc/codes"
19+
"google.golang.org/protobuf/types/known/structpb"
1920

2021
"github.com/envoyproxy/ai-gateway/internal/apischema/openai"
2122
"github.com/envoyproxy/ai-gateway/internal/filterapi"
@@ -36,7 +37,7 @@ type modelsProcessor struct {
3637
var _ Processor = (*modelsProcessor)(nil)
3738

3839
// NewModelsProcessor creates a new processor that returns the list of declared models.
39-
func NewModelsProcessor(config *filterapi.RuntimeConfig, requestHeaders map[string]string, logger *slog.Logger, isUpstreamFilter bool, _ bool) (Processor, error) {
40+
func NewModelsProcessor(config *filterapi.RuntimeConfig, requestHeaders map[string]string, _ map[string]*structpb.Struct, logger *slog.Logger, isUpstreamFilter bool, _ bool) (Processor, error) {
4041
if isUpstreamFilter {
4142
return passThroughProcessor{}, nil
4243
}

internal/extproc/models_processor_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func TestModels_ProcessRequestHeaders(t *testing.T) {
3434
CreatedAt: now,
3535
},
3636
}}
37-
p, err := NewModelsProcessor(cfg, nil, slog.Default(), false, false)
37+
p, err := NewModelsProcessor(cfg, nil, nil, slog.Default(), false, false)
3838
require.NoError(t, err)
3939
res, err := p.ProcessRequestHeaders(t.Context(), &corev3.HeaderMap{
4040
Headers: []*corev3.HeaderValue{{Key: "foo", Value: "bar"}},

internal/extproc/processor.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@ import (
1111

1212
corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
1313
extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
14+
"google.golang.org/protobuf/types/known/structpb"
1415

1516
"github.com/envoyproxy/ai-gateway/internal/filterapi"
1617
)
1718

1819
// ProcessorFactory is the factory function used to create new instances of a processor.
19-
type ProcessorFactory func(_ *filterapi.RuntimeConfig, _ map[string]string, _ *slog.Logger, isUpstreamFilter bool, enableRedaction bool) (Processor, error)
20+
// filterMetadata carries the Envoy filter dynamic metadata from ProcessingRequest.MetadataContext,
21+
// keyed by filter namespace. This is populated once per request on the RequestHeaders phase.
22+
type ProcessorFactory func(_ *filterapi.RuntimeConfig, _ map[string]string, filterMetadata map[string]*structpb.Struct, _ *slog.Logger, isUpstreamFilter bool, enableRedaction bool) (Processor, error)
2023

2124
// Processor is the interface for the processor which corresponds to a single gRPC stream per the external processor filter.
2225
// This decouples the processor implementation detail from the server implementation.

0 commit comments

Comments
 (0)