Skip to content

Commit 3b2f788

Browse files
committed
feat: typed ExternalModel reconciler with cross-watch and legacy support
Updates the model-provider-resolver plugin to watch both CRD API groups: - inference.opendatahub.io ExternalModel (typed client, cross-watches ExternalProvider for credential/config propagation) - maas.opendatahub.io ExternalModel (legacy unstructured, backward compat) Adds config field to model store and ModelConfigKey for CycleState. Registers scheme in factory for typed reconciler support.
1 parent b0a90fa commit 3b2f788

6 files changed

Lines changed: 121 additions & 180 deletions

File tree

pkg/plugins/common/state/state-keys.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,5 @@ const (
2222
ModelKey = "model"
2323
CredsRefName = "credential-ref-name"
2424
CredsRefNamespace = "credential-ref-namespace"
25+
ModelConfigKey = "model-config"
2526
)

pkg/plugins/model-provider-resolver/external_model_reconciler.go

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,39 +19,88 @@ package model_provider_resolver
1919
import (
2020
"context"
2121
"fmt"
22+
"time"
2223

2324
"k8s.io/apimachinery/pkg/api/errors"
2425
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
2526
"k8s.io/apimachinery/pkg/runtime/schema"
27+
"k8s.io/apimachinery/pkg/types"
2628
ctrl "sigs.k8s.io/controller-runtime"
2729
"sigs.k8s.io/controller-runtime/pkg/client"
2830
"sigs.k8s.io/controller-runtime/pkg/log"
2931
logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/common/observability/logging"
32+
33+
inferencev1alpha1 "github.com/opendatahub-io/ai-gateway-payload-processing/api/inference/v1alpha1"
3034
)
3135

32-
// externalModelGVK is the GroupVersionKind for ExternalModel CRD.
33-
var externalModelGVK = schema.GroupVersionKind{
36+
// legacyExternalModelGVK is the old MaaS ExternalModel CRD (maas.opendatahub.io).
37+
var legacyExternalModelGVK = schema.GroupVersionKind{
3438
Group: "maas.opendatahub.io",
3539
Version: "v1alpha1",
3640
Kind: "ExternalModel",
3741
}
3842

39-
// externalModelReconciler watches ExternalModel CRDs (via unstructured client)
40-
// and updates the model store with provider and credential information.
43+
// externalModelReconciler watches inference.opendatahub.io ExternalModel CRDs
44+
// and resolves provider info from the provider store.
4145
type externalModelReconciler struct {
4246
client.Reader
43-
store *modelInfoStore
47+
modelStore *modelInfoStore
48+
providerStore *providerInfoStore
4449
}
4550

46-
// Reconcile handles create/update/delete events for ExternalModel resources.
47-
// The ExternalModel CR name is used as the model key in the store, matching
48-
// the model name in inference request bodies.
4951
func (r *externalModelReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
5052
logger := log.FromContext(ctx).V(logutil.DEFAULT)
5153
logger.Info("reconciling ExternalModel", "name", req.Name, "namespace", req.Namespace)
5254

55+
model := &inferencev1alpha1.ExternalModel{}
56+
err := r.Get(ctx, req.NamespacedName, model)
57+
if err != nil && !errors.IsNotFound(err) {
58+
return ctrl.Result{}, fmt.Errorf("unable to get ExternalModel: %w", err)
59+
}
60+
61+
if errors.IsNotFound(err) || !model.GetDeletionTimestamp().IsZero() {
62+
r.modelStore.deleteExternalModel(req.NamespacedName)
63+
logger.Info("ExternalModel removed from store", "name", req.Name, "namespace", req.Namespace)
64+
return ctrl.Result{}, nil
65+
}
66+
67+
// CRD validation ensures at least one ref (MinItems=1)
68+
ref := model.Spec.ExternalProviderRefs[0]
69+
70+
providerKey := types.NamespacedName{Namespace: req.Namespace, Name: ref.Ref.Name}
71+
providerInfo, found := r.providerStore.get(providerKey)
72+
if !found {
73+
logger.Info("ExternalProvider not yet available, requeuing", "provider", ref.Ref.Name)
74+
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
75+
}
76+
77+
info := &externalModelInfo{
78+
provider: providerInfo.provider,
79+
targetModel: ref.TargetModel,
80+
secretName: providerInfo.secretName,
81+
secretNamespace: providerInfo.secretNamespace,
82+
config: providerInfo.config,
83+
}
84+
r.modelStore.addOrUpdateExternalModel(req.NamespacedName, info)
85+
86+
logger.Info("updated model store", "provider", providerInfo.provider, "targetModel", ref.TargetModel)
87+
return ctrl.Result{}, nil
88+
}
89+
90+
// legacyExternalModelReconciler handles the flat maas.opendatahub.io ExternalModel
91+
// CRD structure (spec.provider, spec.targetModel, spec.credentialRef).
92+
// Uses unstructured client because the types are in a different repo.
93+
type legacyExternalModelReconciler struct {
94+
client.Reader
95+
store *modelInfoStore
96+
}
97+
98+
func (r *legacyExternalModelReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
99+
logger := log.FromContext(ctx).V(logutil.DEFAULT)
100+
logger.Info("reconciling legacy ExternalModel", "name", req.Name, "namespace", req.Namespace)
101+
53102
obj := &unstructured.Unstructured{}
54-
obj.SetGroupVersionKind(externalModelGVK)
103+
obj.SetGroupVersionKind(legacyExternalModelGVK)
55104

56105
err := r.Get(ctx, req.NamespacedName, obj)
57106
if err != nil && !errors.IsNotFound(err) {
@@ -68,12 +117,11 @@ func (r *externalModelReconciler) Reconcile(ctx context.Context, req ctrl.Reques
68117
targetModel, _, _ := unstructured.NestedString(obj.Object, "spec", "targetModel")
69118
credsName, _, _ := unstructured.NestedString(obj.Object, "spec", "credentialRef", "name")
70119

71-
// targetModel is the model that will be used in the request body when getting inference requests.
72120
info := &externalModelInfo{
73121
provider: provider,
74122
targetModel: targetModel,
75123
secretName: credsName,
76-
secretNamespace: req.Namespace, // secret namespace is always the namespace of the ExternalModel
124+
secretNamespace: req.Namespace,
77125
}
78126
r.store.addOrUpdateExternalModel(req.NamespacedName, info)
79127

pkg/plugins/model-provider-resolver/plugin.go

Lines changed: 60 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,18 @@ import (
2424

2525
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
2626
"k8s.io/apimachinery/pkg/types"
27+
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
2728
"sigs.k8s.io/controller-runtime/pkg/builder"
2829
"sigs.k8s.io/controller-runtime/pkg/client"
30+
"sigs.k8s.io/controller-runtime/pkg/handler"
2931
"sigs.k8s.io/controller-runtime/pkg/log"
32+
"sigs.k8s.io/controller-runtime/pkg/reconcile"
3033
"sigs.k8s.io/gateway-api-inference-extension/pkg/bbr/framework"
3134
errcommon "sigs.k8s.io/gateway-api-inference-extension/pkg/common/error"
3235
logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/common/observability/logging"
3336
"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/framework/interface/plugin"
3437

38+
inferencev1alpha1 "github.com/opendatahub-io/ai-gateway-payload-processing/api/inference/v1alpha1"
3539
"github.com/opendatahub-io/ai-gateway-payload-processing/pkg/plugins/common/state"
3640
)
3741

@@ -44,6 +48,8 @@ var _ framework.RequestProcessor = &ModelProviderResolverPlugin{}
4448

4549
// ModelProviderResolverFactory defines the factory function for ModelProviderResolverPlugin
4650
func ModelProviderResolverFactory(name string, _ json.RawMessage, handle framework.Handle) (framework.BBRPlugin, error) {
51+
utilruntime.Must(inferencev1alpha1.AddToScheme(handle.Client().Scheme()))
52+
4753
plugin, err := NewModelProviderResolver(handle.ReconcilerBuilder, handle.Client())
4854
if err != nil {
4955
return nil, fmt.Errorf("failed to create plugin '%s' - %w", ModelProviderResolverPluginType, err)
@@ -52,55 +58,78 @@ func ModelProviderResolverFactory(name string, _ json.RawMessage, handle framewo
5258
return plugin.WithName(name), nil
5359
}
5460

55-
func NewModelProviderResolver(reconcilerBuilder func() *builder.Builder, clientReader client.Reader) (*ModelProviderResolverPlugin, error) {
56-
modelInfoStore := newModelInfoStore()
57-
reconciler := &externalModelReconciler{
58-
Reader: clientReader,
59-
store: modelInfoStore,
60-
}
61+
func NewModelProviderResolver(reconcilerBuilder func() *builder.Builder, k8sClient client.Client) (*ModelProviderResolverPlugin, error) {
62+
providerStore := newProviderInfoStore()
63+
modelStore := newModelInfoStore()
6164

62-
// Watch ExternalModel CRDs directly (no MaaS dependency)
63-
obj := &unstructured.Unstructured{}
64-
obj.SetGroupVersionKind(externalModelGVK)
65+
// Watch ExternalProvider CRDs (inference.opendatahub.io/v1alpha1) using typed client
66+
providerReconciler := &externalProviderReconciler{Reader: k8sClient, store: providerStore}
67+
if err := reconcilerBuilder().For(&inferencev1alpha1.ExternalProvider{}).Complete(providerReconciler); err != nil {
68+
return nil, fmt.Errorf("failed to register ExternalProvider reconciler for plugin '%s' - %w", ModelProviderResolverPluginType, err)
69+
}
6570

66-
if err := reconcilerBuilder().For(obj).Complete(reconciler); err != nil {
71+
// Watch ExternalModel CRDs (inference.opendatahub.io/v1alpha1) using typed client.
72+
// Cross-watch ExternalProviders so credential/endpoint changes propagate to modelStore.
73+
modelReconciler := &externalModelReconciler{Reader: k8sClient, modelStore: modelStore, providerStore: providerStore}
74+
mapProviderToModels := func(ctx context.Context, obj client.Object) []reconcile.Request {
75+
provider := obj.(*inferencev1alpha1.ExternalProvider)
76+
modelList := &inferencev1alpha1.ExternalModelList{}
77+
if err := k8sClient.List(ctx, modelList, client.InNamespace(provider.Namespace)); err != nil {
78+
log.FromContext(ctx).Error(err, "failed to list ExternalModels for provider mapping",
79+
"provider", provider.Name, "namespace", provider.Namespace)
80+
return nil
81+
}
82+
var requests []reconcile.Request
83+
for i := range modelList.Items {
84+
for _, ref := range modelList.Items[i].Spec.ExternalProviderRefs {
85+
if ref.Ref.Name == provider.Name {
86+
requests = append(requests, reconcile.Request{
87+
NamespacedName: types.NamespacedName{Name: modelList.Items[i].Name, Namespace: modelList.Items[i].Namespace},
88+
})
89+
}
90+
}
91+
}
92+
return requests
93+
}
94+
if err := reconcilerBuilder().
95+
For(&inferencev1alpha1.ExternalModel{}).
96+
Named("inference-externalmodel").
97+
Watches(&inferencev1alpha1.ExternalProvider{}, handler.EnqueueRequestsFromMapFunc(mapProviderToModels)).
98+
Complete(modelReconciler); err != nil {
6799
return nil, fmt.Errorf("failed to register ExternalModel reconciler for plugin '%s' - %w", ModelProviderResolverPluginType, err)
68100
}
69101

102+
// Legacy: watch maas.opendatahub.io ExternalModels for backward compatibility
103+
legacyObj := &unstructured.Unstructured{}
104+
legacyObj.SetGroupVersionKind(legacyExternalModelGVK)
105+
legacyReconciler := &legacyExternalModelReconciler{Reader: k8sClient, store: modelStore}
106+
if err := reconcilerBuilder().For(legacyObj).Named("legacy-externalmodel").Complete(legacyReconciler); err != nil {
107+
return nil, fmt.Errorf("failed to register legacy ExternalModel reconciler for plugin '%s' - %w", ModelProviderResolverPluginType, err)
108+
}
109+
70110
return &ModelProviderResolverPlugin{
71111
typedName: plugin.TypedName{Type: ModelProviderResolverPluginType, Name: ModelProviderResolverPluginType},
72-
modelInfoStore: modelInfoStore,
112+
modelInfoStore: modelStore,
73113
}, nil
74114
}
75115

76116
// ModelProviderResolverPlugin resolves model names to provider info by watching ExternalModel CRDs.
77-
// It writes the model, provider and credential reference to CycleState for downstream plugins
78-
// (api-translation, api-key-injection).
79117
type ModelProviderResolverPlugin struct {
80118
typedName plugin.TypedName
81119
modelInfoStore *modelInfoStore
82120
}
83121

84-
// TypedName returns the type and name tuple of this plugin instance.
85-
func (p *ModelProviderResolverPlugin) TypedName() plugin.TypedName {
86-
return p.typedName
87-
}
122+
func (p *ModelProviderResolverPlugin) TypedName() plugin.TypedName { return p.typedName }
88123

89-
// WithName sets the name of the plugin instance.
90124
func (p *ModelProviderResolverPlugin) WithName(name string) *ModelProviderResolverPlugin {
91125
p.typedName.Name = name
92126
return p
93127
}
94128

95-
// ProcessRequest reads the model name from the request body, resolves the provider
96-
// from the modelInfoStore (populated by ExternalModel reconciler), and writes model, provider
97-
// and credential reference info to CycleState.
98129
func (p *ModelProviderResolverPlugin) ProcessRequest(ctx context.Context, cycleState *framework.CycleState, request *framework.InferenceRequest) error {
99-
logger := log.FromContext(ctx).V(logutil.DEFAULT)
100-
101130
model, ok := request.Body["model"].(string)
102131
if !ok || model == "" {
103-
return nil // not an inference request (e.g. API key management, model listing)
132+
return nil
104133
}
105134

106135
log.FromContext(ctx).V(logutil.VERBOSE).Info("received incoming request", "path", request.Headers[":path"])
@@ -116,37 +145,33 @@ func (p *ModelProviderResolverPlugin) ProcessRequest(ctx context.Context, cycleS
116145
log.FromContext(ctx).V(logutil.VERBOSE).Info("exported namespaced name from path", "key", modelKey)
117146

118147
externalModelInfo, found := p.modelInfoStore.getModelInfo(modelKey)
119-
if !found { // info is stored only for external models
120-
return nil // this is not considered an error, we just need to skip if it's internal model
148+
if !found {
149+
return nil
121150
}
122151

123-
if !strings.HasSuffix(relativePath, "chat/completions") { // no support for other input types
124-
logger.Error(nil, "unsupported route for external model", "model", modelKey.String(), "path", relativePath)
152+
if !strings.HasSuffix(relativePath, "chat/completions") {
125153
return errcommon.Error{Code: errcommon.BadRequest, Msg: "only /chat/completions input type is supported"}
126154
}
127155

128-
// if there's a mismatch it's an error, we don't want to proceed
129156
if externalModelInfo.targetModel != model {
130-
logger.Error(nil, "model mismatch between request body and ExternalModel", "requestModel", model, "externalModel", externalModelInfo.targetModel)
131157
return errcommon.Error{Code: errcommon.NotFound, Msg: fmt.Sprintf("model in request body '%s' doesn't match ExternalModel", model)}
132158
}
133159

134-
// info of external model written to cycle state for next plugins
135160
cycleState.Write(state.ProviderKey, externalModelInfo.provider)
136161
cycleState.Write(state.ModelKey, externalModelInfo.targetModel)
137162
cycleState.Write(state.CredsRefName, externalModelInfo.secretName)
138163
cycleState.Write(state.CredsRefNamespace, externalModelInfo.secretNamespace)
164+
if len(externalModelInfo.config) > 0 {
165+
cycleState.Write(state.ModelConfigKey, externalModelInfo.config)
166+
}
139167

140-
logger.Info("external model resolved", "model", modelKey.String(), "provider", externalModelInfo.provider)
141168
return nil
142169
}
143170

144171
func sanitizePath(relativeUrlPath string) string {
145172
relativeUrlPath = strings.TrimSpace(relativeUrlPath)
146-
147173
if index := strings.IndexByte(relativeUrlPath, '?'); index >= 0 {
148-
relativeUrlPath = relativeUrlPath[:index] // remove query params
174+
relativeUrlPath = relativeUrlPath[:index]
149175
}
150-
151176
return strings.Trim(relativeUrlPath, "/")
152177
}

pkg/plugins/model-provider-resolver/provider_store.go

Lines changed: 0 additions & 61 deletions
This file was deleted.

0 commit comments

Comments
 (0)