Skip to content

Commit 735f527

Browse files
committed
feat(prefix): auto-tune prefix match cap from model max_model_len
The approximate-prefix-cache producer caps prefix matching at a fixed maxPrefixTokensToMatch (default 128k). On models with a larger context window that under-matches long shared prefixes, and it's one more knob to set per model. When autoTune is on and maxPrefixTokensToMatch is left at its default, derive the cap from each endpoint's max_model_len instead, falling back to the existing default when it isn't known. An explicitly configured cap is always respected. max_model_len rides the existing /v1/models attribute. Rather than poll that endpoint on a timer for information that's fixed for an endpoint's lifetime, a new models-endpoint-extractor fetches /v1/models once when an endpoint is added and stores the model list (now carrying max_model_len) as an attribute. It registers as the default producer of that attribute and self-wires an endpoint-notification source, so the producer only declares the dependency and reads the value. A failed or slow fetch leaves the attribute unset and the cap falls back to the default. Signed-off-by: Mayur Das <mayur.das@neevcloud.com>
1 parent 546ea56 commit 735f527

13 files changed

Lines changed: 715 additions & 20 deletions

File tree

cmd/epp/runner/runner.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,9 @@ func (r *Runner) registerInTreePlugins() {
499499
// data layer models source/extractor
500500
fwkplugin.Register(srcmodels.ModelsDataSourceType, srcmodels.ModelDataSourceFactory)
501501
fwkplugin.Register(attrmodels.ModelsExtractorType, extmodels.ModelServerExtractorFactory)
502+
// Default producer of the /v1/models attribute: fetches once per endpoint on
503+
// add rather than polling, and self-wires its endpoint-notification source.
504+
fwkplugin.RegisterAsDefaultProducer(extmodels.ModelsEndpointExtractorType, extmodels.ModelEndpointExtractorFactory, attrmodels.ModelsAttributeKey)
502505

503506
fwkplugin.Register(prefix.PrefixCacheScorerPluginType, prefix.PrefixCachePluginFactory)
504507
fwkplugin.Register(maxscore.MaxScorePickerType, maxscore.MaxScorePickerFactory)

pkg/epp/framework/plugins/datalayer/attribute/models/data_types.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ type ModelDataCollection []ModelData
3737
type ModelData struct {
3838
ID string `json:"id"`
3939
Parent string `json:"parent,omitempty"`
40+
// MaxModelLen is the model's context window in tokens as reported by the
41+
// model server; zero when the server does not report it.
42+
MaxModelLen int `json:"max_model_len,omitempty"`
4043
}
4144

4245
// String returns a string representation of the model info
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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 models
18+
19+
import (
20+
"testing"
21+
22+
"github.com/stretchr/testify/assert"
23+
)
24+
25+
// TestModelDataCollectionClone verifies Clone copies entries (including
26+
// MaxModelLen) and is independent of the original.
27+
func TestModelDataCollectionClone(t *testing.T) {
28+
assert.Nil(t, ModelDataCollection(nil).Clone())
29+
30+
orig := ModelDataCollection{{ID: "m1", MaxModelLen: 100}, {ID: "m2", MaxModelLen: 200}}
31+
cloned, ok := orig.Clone().(ModelDataCollection)
32+
assert.True(t, ok)
33+
assert.Equal(t, orig, cloned)
34+
35+
// Mutating the clone must not affect the original.
36+
cloned[0].MaxModelLen = 999
37+
assert.Equal(t, 100, orig[0].MaxModelLen)
38+
}

pkg/epp/framework/plugins/datalayer/extractor/models/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,26 @@ modelData, ok := attr.(models.ModelDataCollection)
2727
## Configuration
2828

2929
No configuration parameters.
30+
31+
## Once-per-endpoint variant
32+
33+
**Type:** `models-endpoint-extractor`
34+
35+
`ModelEndpointExtractor` produces the same `ModelsAttributeKey` attribute but is
36+
driven by endpoint lifecycle events instead of a poll loop. On endpoint add it
37+
fetches `/v1/models` once and stores the parsed `ModelDataCollection`; the model
38+
list is fixed for an endpoint's lifetime, so it is not re-fetched on a timer. A
39+
failed fetch is logged and skipped, leaving the attribute unset so consumers fall
40+
back to their default.
41+
42+
It is registered as the default producer of `ModelsAttributeKey` and self-wires
43+
to an `endpoint-notification-source` (auto-created when absent), so any consumer
44+
that declares the attribute as a dependency gets it without extra configuration.
45+
46+
### Configuration
47+
48+
| Field | Default | Description |
49+
|---|---|---|
50+
| `scheme` | `http` | Scheme used to reach the model server. |
51+
| `path` | `/v1/models` | Path fetched on the model server. |
52+
| `insecureSkipVerify` | `true` | Skip TLS verification when `scheme` is `https`. |
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
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 models
18+
19+
import (
20+
"context"
21+
"encoding/json"
22+
"fmt"
23+
"time"
24+
25+
"sigs.k8s.io/controller-runtime/pkg/log"
26+
27+
"github.com/llm-d/llm-d-router/pkg/common/observability/logging"
28+
fwkdl "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/datalayer"
29+
fwkplugin "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/plugin"
30+
attrmodels "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/datalayer/attribute/models"
31+
srchttp "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/datalayer/source/http"
32+
srcnotifications "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/datalayer/source/notifications"
33+
)
34+
35+
// ModelsEndpointExtractorType identifies the extractor that fetches /v1/models
36+
// once per endpoint, as opposed to the polling models-data-extractor.
37+
const ModelsEndpointExtractorType = "models-endpoint-extractor"
38+
39+
// fetchTimeout bounds a single /v1/models fetch so a slow or unresponsive model
40+
// server cannot keep the background fetch goroutine alive indefinitely.
41+
const fetchTimeout = 10 * time.Second
42+
43+
// Model-server connection defaults, matching the polling models source so both
44+
// reach the same endpoint with the same TLS handling.
45+
const (
46+
defaultModelsScheme = "http"
47+
defaultModelsPath = "/v1/models"
48+
defaultModelsInsecureSkipVerify = true
49+
)
50+
51+
// Assert the extractor produces an attribute, runs on endpoint lifecycle events,
52+
// and self-wires its source dependency.
53+
var (
54+
_ fwkplugin.ProducerPlugin = (*ModelEndpointExtractor)(nil)
55+
_ fwkdl.EndpointExtractor = (*ModelEndpointExtractor)(nil)
56+
_ fwkdl.Registrant = (*ModelEndpointExtractor)(nil)
57+
)
58+
59+
// modelsEndpointExtractorParams configures the model-server endpoint and TLS
60+
// handling. It mirrors the polling models source so operators can point the
61+
// extractor at an https model server.
62+
type modelsEndpointExtractorParams struct {
63+
Scheme string `json:"scheme"`
64+
Path string `json:"path"`
65+
InsecureSkipVerify bool `json:"insecureSkipVerify"`
66+
}
67+
68+
// ModelEndpointExtractor fetches /v1/models once when an endpoint is added and
69+
// stores the parsed model list (including max_model_len) as an endpoint
70+
// attribute. The model list is fixed for an endpoint's lifetime, so it does not
71+
// re-fetch on a timer the way the polling extractor does.
72+
type ModelEndpointExtractor struct {
73+
typedName fwkplugin.TypedName
74+
dk fwkplugin.DataKey
75+
// fetcher performs the one-shot GET and JSON parse, reusing the HTTP source's
76+
// scheme/TLS handling. Only its Poll method is used; the polling Dispatch loop
77+
// is never driven.
78+
fetcher *srchttp.HTTPDataSource[*ModelResponse]
79+
}
80+
81+
// NewModelEndpointExtractor builds an extractor that fetches from
82+
// scheme://<endpoint>/path, verifying the server certificate unless insecure is set.
83+
func NewModelEndpointExtractor(name, scheme, path string, insecure bool) (*ModelEndpointExtractor, error) {
84+
fetcher, err := srchttp.NewHTTPDataSource(scheme, path, insecure, ModelsEndpointExtractorType, name, ParseModels)
85+
if err != nil {
86+
return nil, fmt.Errorf("failed to create models fetcher: %w", err)
87+
}
88+
return &ModelEndpointExtractor{
89+
typedName: fwkplugin.TypedName{Type: ModelsEndpointExtractorType, Name: name},
90+
dk: attrmodels.ModelsAttributeKey,
91+
fetcher: fetcher,
92+
}, nil
93+
}
94+
95+
// TypedName returns the plugin type and name.
96+
func (me *ModelEndpointExtractor) TypedName() fwkplugin.TypedName { return me.typedName }
97+
98+
// Produces declares the /v1/models attribute this extractor populates so the
99+
// framework can wire it to consumers of that attribute.
100+
func (me *ModelEndpointExtractor) Produces() map[fwkplugin.DataKey]any {
101+
return map[fwkplugin.DataKey]any{me.dk: attrmodels.ModelDataCollection{}}
102+
}
103+
104+
// Extract records the model list for an added endpoint. The fetch runs in the
105+
// background and returns immediately: endpoint add/update is processed serially,
106+
// so a slow or unreachable model server must not stall endpoint provisioning.
107+
// Until the fetch completes the attribute stays unset and the consumer falls
108+
// back to its default. Delete events need no work; the attribute leaves with the
109+
// endpoint.
110+
func (me *ModelEndpointExtractor) Extract(ctx context.Context, event fwkdl.EndpointEvent) error {
111+
// Only an add/update carries model data to extract; deletes carry none.
112+
if event.Type != fwkdl.EventAddOrUpdate {
113+
return nil
114+
}
115+
ep := event.Endpoint
116+
if ep == nil || ep.GetMetadata() == nil {
117+
return nil
118+
}
119+
// The model list is fixed for an endpoint's lifetime, so fetch only once even
120+
// though add/update may fire repeatedly for the same endpoint.
121+
if _, ok := ep.GetAttributes().Get(me.dk.String()); ok {
122+
return nil
123+
}
124+
go me.fetchAndStore(ctx, ep)
125+
return nil
126+
}
127+
128+
// fetchAndStore fetches /v1/models once, under a bounded timeout, and records the
129+
// model list (including max_model_len) as an endpoint attribute. A failed fetch
130+
// is logged and dropped, leaving the attribute unset so the consumer falls back
131+
// to its default.
132+
func (me *ModelEndpointExtractor) fetchAndStore(ctx context.Context, ep fwkdl.Endpoint) {
133+
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
134+
defer cancel()
135+
resp, err := me.fetcher.Poll(ctx, ep)
136+
if err != nil {
137+
log.FromContext(ctx).V(logging.DEBUG).Info("failed to fetch /v1/models",
138+
"endpoint", ep.GetMetadata().NamespacedName, "err", err)
139+
return
140+
}
141+
ep.GetAttributes().Put(me.dk.String(), attrmodels.ModelDataCollection(resp.Data))
142+
}
143+
144+
// RegisterDependencies binds this extractor to an endpoint-notification-source,
145+
// auto-creating that source when the configuration does not already define one.
146+
// This is what makes the extractor wire itself in as a default source.
147+
func (me *ModelEndpointExtractor) RegisterDependencies(r fwkdl.Registrar) error {
148+
return r.Register(fwkdl.PendingRegistration{
149+
Owner: me.TypedName(),
150+
SourceType: srcnotifications.EndpointNotificationSourceType,
151+
Extractor: me,
152+
DefaultSource: srcnotifications.NewEndpointDataSource(
153+
srcnotifications.EndpointNotificationSourceType, srcnotifications.EndpointNotificationSourceType),
154+
})
155+
}
156+
157+
// ModelEndpointExtractorFactory instantiates the extractor from optional JSON
158+
// parameters, defaulting to an http model server with TLS verification skipped.
159+
func ModelEndpointExtractorFactory(name string, parameters *json.Decoder, _ fwkplugin.Handle) (fwkplugin.Plugin, error) {
160+
params := modelsEndpointExtractorParams{
161+
Scheme: defaultModelsScheme,
162+
Path: defaultModelsPath,
163+
InsecureSkipVerify: defaultModelsInsecureSkipVerify,
164+
}
165+
// Overlay the defaults with any configured values.
166+
if parameters != nil {
167+
if err := parameters.Decode(&params); err != nil {
168+
return nil, err
169+
}
170+
}
171+
return NewModelEndpointExtractor(name, params.Scheme, params.Path, params.InsecureSkipVerify)
172+
}

0 commit comments

Comments
 (0)