Skip to content

Commit c089071

Browse files
committed
Add generic endpoint attribute filter for configured endpoint attributes
Signed-off-by: Alok Behera <alokbeherak061@gmail.com>
1 parent b65a763 commit c089071

5 files changed

Lines changed: 564 additions & 0 deletions

File tree

cmd/epp/runner/runner.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ import (
101101
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/requesthandling/parsers/vllmgrpc"
102102
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/requesthandling/parsers/vllmhttp"
103103
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/filter/bylabel"
104+
endpointattributefilter "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/filter/endpointattribute"
104105
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/filter/prefixcacheaffinity"
105106
sessionaffinityfilter "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/filter/sessionaffinity"
106107
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/filter/sloheadroomtier"
@@ -543,6 +544,7 @@ func (r *Runner) registerInTreePlugins() {
543544
fwkplugin.Register(bylabel.EncodeRoleType, bylabel.EncodeRoleFactory)
544545
fwkplugin.Register(bylabel.DecodeRoleType, bylabel.DecodeRoleFactory)
545546
fwkplugin.Register(bylabel.PrefillRoleType, bylabel.PrefillRoleFactory)
547+
fwkplugin.Register(endpointattributefilter.EndpointAttributeFilterType, endpointattributefilter.EndpointAttributeFilterFactory)
546548
fwkplugin.Register(sessionaffinityfilter.SessionAffinityType, sessionaffinityfilter.Factory)
547549

548550
// dataparallel profile handler
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Endpoint Attribute Filter Plugin
2+
3+
**Type:** `endpoint-attribute-filter`
4+
5+
This plugin filters candidate endpoints by a single configured numeric endpoint attribute.
6+
7+
## What it does
8+
9+
For each scheduling cycle, the plugin reads the configured attribute (`attribute`) from each candidate endpoint and keeps only the endpoints whose value satisfies the configured algorithm. This PR supports the `threshold` algorithm; `percentile`, `topK` and `range` are planned follow-ups.
10+
11+
With `threshold`, an endpoint is kept when
12+
13+
\[
14+
\text{value(endpoint)} \ \langle op \rangle\ \text{threshold.value}
15+
\]
16+
17+
is true for the configured `threshold.operator`.
18+
19+
Policies for the awkward cases:
20+
21+
- **`onMissing`** — what happens to an endpoint that does not have the attribute: `Pass` keeps it (the default), `Fail` drops it.
22+
- **`fallbackOnEmpty`** — when every endpoint is filtered out and this is `true`, the original candidate list is returned unchanged, so the request can still be routed somewhere. Default `false`.
23+
24+
The attribute is expected to be a numeric custom metric produced by the core metrics extractor (see the [metrics extractor](../../../datalayer/extractor/metrics/README.md)), stored as a `ScalarMetricValue` endpoint attribute.
25+
26+
## Inputs consumed
27+
28+
The plugin consumes:
29+
30+
- the configured `attribute` (`ScalarMetricValue`)
31+
32+
## Configuration
33+
34+
| Parameter | Required | Description |
35+
|---------------------------------|----------|------------------------------------------------------------------------------------------|
36+
| `attribute` | yes | Endpoint attribute to read, e.g. `num_requests_running`. |
37+
| `onMissing` | no | `Pass` (default) or `Fail` — keep or drop endpoints missing the attribute. |
38+
| `fallbackOnEmpty` | no | When `true`, return the unfiltered candidates if every endpoint was dropped. Default `false`. |
39+
| `algorithm.type` | yes | Only `threshold` is currently supported. |
40+
| `algorithm.threshold.operator` | yes | `LessThan`, `LessThanOrEqual`, `GreaterThan`, `GreaterThanOrEqual`, `Equal` or `NotEqual`. |
41+
| `algorithm.threshold.value` | yes | The value compared against the endpoint's attribute. |
42+
43+
**Configuration Example:**
44+
```yaml
45+
plugins:
46+
- type: endpoint-attribute-filter
47+
name: drop-loaded-endpoints
48+
parameters:
49+
attribute: "num_requests_running"
50+
onMissing: "Pass"
51+
fallbackOnEmpty: true
52+
algorithm:
53+
type: "threshold"
54+
threshold:
55+
operator: "LessThan"
56+
value: 10
57+
schedulingProfiles:
58+
- name: default
59+
plugins:
60+
- pluginRef: drop-loaded-endpoints
61+
```
62+
63+
## See also
64+
65+
The `endpoint-attribute-scorer` plugin ([llm-d/llm-d-router#1620](https://github.com/llm-d/llm-d-router/pull/1620)) is the scoring counterpart of this filter: instead of dropping endpoints, it ranks them by the same kind of configured attribute.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/*
2+
Copyright 2026 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
// Package endpointattribute provides a generic filter plugin that filters
18+
// candidate endpoints by a single configured numeric endpoint attribute.
19+
package endpointattribute
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
/*
2+
Copyright 2026 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package endpointattribute
18+
19+
import (
20+
"context"
21+
"encoding/json"
22+
"errors"
23+
"fmt"
24+
25+
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/plugin"
26+
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/scheduling"
27+
attrmetrics "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/datalayer/attribute/metrics"
28+
)
29+
30+
const (
31+
// EndpointAttributeFilterType is the type of the EndpointAttributeFilter.
32+
EndpointAttributeFilterType = "endpoint-attribute-filter"
33+
34+
onMissingPass = "Pass"
35+
onMissingFail = "Fail"
36+
37+
algorithmThreshold = "threshold"
38+
39+
operatorLessThan = "LessThan"
40+
operatorLessThanOrEqual = "LessThanOrEqual"
41+
operatorGreaterThan = "GreaterThan"
42+
operatorGreaterThanOrEqual = "GreaterThanOrEqual"
43+
operatorEqual = "Equal"
44+
operatorNotEqual = "NotEqual"
45+
)
46+
47+
// thresholdParameters keeps endpoints whose attribute value compares true
48+
// against the configured value.
49+
type thresholdParameters struct {
50+
Operator string `json:"operator"`
51+
Value float64 `json:"value"`
52+
}
53+
54+
// algorithmParameters selects the filtering algorithm. threshold is the only
55+
// algorithm currently supported; percentile, topK and range are planned as
56+
// follow-ups.
57+
type algorithmParameters struct {
58+
Type string `json:"type"`
59+
Threshold *thresholdParameters `json:"threshold,omitempty"`
60+
}
61+
62+
type parameters struct {
63+
Attribute string `json:"attribute"`
64+
// OnMissing decides what happens to endpoints that do not have the
65+
// attribute: "Pass" keeps them (the default), "Fail" drops them.
66+
OnMissing string `json:"onMissing"`
67+
// FallbackOnEmpty returns the unfiltered candidates when every endpoint
68+
// was filtered out, so the request can still be routed somewhere.
69+
FallbackOnEmpty bool `json:"fallbackOnEmpty"`
70+
Algorithm algorithmParameters `json:"algorithm"`
71+
}
72+
73+
// compile-time type assertion
74+
var _ scheduling.Filter = &EndpointAttributeFilter{}
75+
76+
// EndpointAttributeFilterFactory defines the factory function for EndpointAttributeFilter.
77+
func EndpointAttributeFilterFactory(name string, rawParameters *json.Decoder, _ plugin.Handle) (plugin.Plugin, error) {
78+
var params parameters
79+
if rawParameters != nil {
80+
if err := rawParameters.Decode(&params); err != nil {
81+
return nil, fmt.Errorf("failed to parse the parameters of the '%s' filter - %w", EndpointAttributeFilterType, err)
82+
}
83+
}
84+
85+
return NewEndpointAttributeFilter(name, params)
86+
}
87+
88+
// NewEndpointAttributeFilter validates the given parameters and returns a new
89+
// EndpointAttributeFilter with the given name.
90+
func NewEndpointAttributeFilter(name string, params parameters) (*EndpointAttributeFilter, error) {
91+
if name == "" {
92+
name = EndpointAttributeFilterType
93+
}
94+
if params.Attribute == "" {
95+
return nil, errors.New("endpoint attribute filter requires a non-empty attribute")
96+
}
97+
switch params.OnMissing {
98+
case "":
99+
params.OnMissing = onMissingPass
100+
case onMissingPass, onMissingFail:
101+
default:
102+
return nil, fmt.Errorf("endpoint attribute filter onMissing must be %q or %q, got %q",
103+
onMissingPass, onMissingFail, params.OnMissing)
104+
}
105+
if params.Algorithm.Type != algorithmThreshold {
106+
return nil, fmt.Errorf("endpoint attribute filter algorithm.type must be %q, got %q",
107+
algorithmThreshold, params.Algorithm.Type)
108+
}
109+
threshold := params.Algorithm.Threshold
110+
if threshold == nil {
111+
return nil, fmt.Errorf("endpoint attribute filter requires algorithm.threshold when algorithm.type is %q",
112+
algorithmThreshold)
113+
}
114+
switch threshold.Operator {
115+
case operatorLessThan, operatorLessThanOrEqual, operatorGreaterThan,
116+
operatorGreaterThanOrEqual, operatorEqual, operatorNotEqual:
117+
default:
118+
return nil, fmt.Errorf("endpoint attribute filter threshold.operator must be one of %q, %q, %q, %q, %q, %q, got %q",
119+
operatorLessThan, operatorLessThanOrEqual, operatorGreaterThan,
120+
operatorGreaterThanOrEqual, operatorEqual, operatorNotEqual, threshold.Operator)
121+
}
122+
123+
return &EndpointAttributeFilter{
124+
typedName: plugin.TypedName{Type: EndpointAttributeFilterType, Name: name},
125+
attribute: params.Attribute,
126+
passOnMissing: params.OnMissing == onMissingPass,
127+
fallbackOnEmpty: params.FallbackOnEmpty,
128+
threshold: *threshold,
129+
}, nil
130+
}
131+
132+
// EndpointAttributeFilter filters candidate endpoints by a single configured
133+
// numeric endpoint attribute (produced by the custom metrics extraction
134+
// layer). Endpoints whose attribute value compares true against the
135+
// configured threshold are kept.
136+
type EndpointAttributeFilter struct {
137+
typedName plugin.TypedName
138+
attribute string
139+
// passOnMissing keeps endpoints that do not have the attribute instead of
140+
// dropping them.
141+
passOnMissing bool
142+
// fallbackOnEmpty returns the unfiltered candidates when every endpoint
143+
// was filtered out.
144+
fallbackOnEmpty bool
145+
threshold thresholdParameters
146+
}
147+
148+
// TypedName returns the typed name of the plugin.
149+
func (f *EndpointAttributeFilter) TypedName() plugin.TypedName {
150+
return f.typedName
151+
}
152+
153+
// Consumes returns the list of data that is consumed by the plugin.
154+
func (f *EndpointAttributeFilter) Consumes() map[string]any {
155+
return map[string]any{
156+
f.attribute: attrmetrics.ScalarMetricValue(0),
157+
}
158+
}
159+
160+
// Filter keeps the endpoints whose attribute value satisfies the configured
161+
// threshold. Endpoints missing the attribute are kept or dropped according to
162+
// the onMissing policy. When all endpoints are filtered out and
163+
// fallbackOnEmpty is set, the original candidates are returned.
164+
func (f *EndpointAttributeFilter) Filter(_ context.Context, _ *scheduling.InferenceRequest, endpoints []scheduling.Endpoint) []scheduling.Endpoint {
165+
filtered := make([]scheduling.Endpoint, 0, len(endpoints))
166+
for _, endpoint := range endpoints {
167+
value, ok := attrmetrics.ReadScalarMetricValue(endpoint, f.attribute)
168+
if !ok {
169+
if f.passOnMissing {
170+
filtered = append(filtered, endpoint)
171+
}
172+
continue
173+
}
174+
if f.matches(float64(value)) {
175+
filtered = append(filtered, endpoint)
176+
}
177+
}
178+
179+
if len(filtered) == 0 && f.fallbackOnEmpty {
180+
return endpoints
181+
}
182+
return filtered
183+
}
184+
185+
// matches reports whether the value satisfies the configured threshold.
186+
func (f *EndpointAttributeFilter) matches(value float64) bool {
187+
switch f.threshold.Operator {
188+
case operatorLessThan:
189+
return value < f.threshold.Value
190+
case operatorLessThanOrEqual:
191+
return value <= f.threshold.Value
192+
case operatorGreaterThan:
193+
return value > f.threshold.Value
194+
case operatorGreaterThanOrEqual:
195+
return value >= f.threshold.Value
196+
case operatorEqual:
197+
return value == f.threshold.Value
198+
case operatorNotEqual:
199+
return value != f.threshold.Value
200+
default:
201+
// Unreachable: the operator is validated at construction time.
202+
return false
203+
}
204+
}

0 commit comments

Comments
 (0)