Skip to content

Commit ad16737

Browse files
committed
fix: ext_proc-driven multi-provider routing, weight=0, user-agent metering (opendatahub-io#399)
- model-provider-resolver: set x-ipp-selected-provider header using provider CR name (providerName field) after selectByWeight - selectByWeight: skip refs with weight <= 0; return nil (400 error) when all providers are disabled instead of silent fallback to refs[0] - CRD: allow weight=0 (Minimum changed from 1 to 0) - HTTPRoute reconciler: per-provider route rules with header match on x-ipp-selected-provider, fallback to refs[0]; uses shared constant from plugin package - external-metering: capture User-Agent for client attribution - vertex: add stream_options to unsupported fields strip list
1 parent 68b2a2f commit ad16737

10 files changed

Lines changed: 212 additions & 141 deletions

File tree

api/inference/v1alpha1/externalmodel_types.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,10 @@ type ExternalProviderRef struct {
100100

101101
// Weight determines the relative traffic proportion for this provider binding.
102102
// Higher weight means more traffic. Used for weighted random selection across
103-
// multiple provider refs. Defaults to 1 if not set.
103+
// multiple provider refs. A weight of 0 disables the ref (no traffic routed
104+
// to it). Defaults to 1 if not set.
104105
// +optional
105-
// +kubebuilder:validation:Minimum=1
106+
// +kubebuilder:validation:Minimum=0
106107
// +kubebuilder:validation:Maximum=100
107108
// +kubebuilder:default=1
108109
Weight *int `json:"weight,omitempty"`

config/crd/bases/inference.opendatahub.io_externalmodels.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,10 @@ spec:
139139
description: |-
140140
Weight determines the relative traffic proportion for this provider binding.
141141
Higher weight means more traffic. Used for weighted random selection across
142-
multiple provider refs. Defaults to 1 if not set.
142+
multiple provider refs. A weight of 0 disables the ref (no traffic routed
143+
to it). Defaults to 1 if not set.
143144
maximum: 100
144-
minimum: 1
145+
minimum: 0
145146
type: integer
146147
required:
147148
- apiFormat

pkg/controller/externalmodel/reconciler.go

Lines changed: 127 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package externalmodel
1919
import (
2020
"context"
2121
"fmt"
22+
"strings"
2223

2324
"github.com/go-logr/logr"
2425
"k8s.io/apimachinery/pkg/api/equality"
@@ -37,6 +38,7 @@ import (
3738

3839
inferencev1alpha1 "github.com/opendatahub-io/ai-gateway-payload-processing/api/inference/v1alpha1"
3940
ctrlcommon "github.com/opendatahub-io/ai-gateway-payload-processing/pkg/controller/common"
41+
providerresolver "github.com/opendatahub-io/ai-gateway-payload-processing/pkg/plugins/model-provider-resolver"
4042
)
4143

4244
const (
@@ -111,33 +113,55 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
111113
return ctrl.Result{}, nil
112114
}
113115

116+
// resolvedRef holds the ExternalProvider info resolved for one ExternalProviderRef.
117+
type resolvedRef struct {
118+
providerName string
119+
providerEndpoint string
120+
targetModel string
121+
}
122+
114123
func (r *Reconciler) reconcileHTTPRoute(ctx context.Context, logger logr.Logger, model *inferencev1alpha1.ExternalModel) error {
115124
if len(model.Spec.ExternalProviderRefs) == 0 {
116125
return fmt.Errorf("ExternalModel %q has no externalProviderRefs", model.Name)
117126
}
118-
ref := model.Spec.ExternalProviderRefs[0]
119127

120-
provider := &inferencev1alpha1.ExternalProvider{}
121-
providerKey := types.NamespacedName{Name: ref.Ref.Name, Namespace: model.Namespace}
122-
if err := r.Get(ctx, providerKey, provider); err != nil {
123-
if apierrors.IsNotFound(err) {
124-
return fmt.Errorf("ExternalProvider %q not found in namespace %q", ref.Ref.Name, model.Namespace)
128+
var resolved []resolvedRef
129+
var skipReasons []string
130+
for _, ref := range model.Spec.ExternalProviderRefs {
131+
provider := &inferencev1alpha1.ExternalProvider{}
132+
providerKey := types.NamespacedName{Name: ref.Ref.Name, Namespace: model.Namespace}
133+
if err := r.Get(ctx, providerKey, provider); err != nil {
134+
if apierrors.IsNotFound(err) {
135+
logger.Error(err, "ExternalProvider not found, skipping", "provider", ref.Ref.Name)
136+
skipReasons = append(skipReasons, fmt.Sprintf("ExternalProvider %q not found in namespace %q", ref.Ref.Name, model.Namespace))
137+
continue
138+
}
139+
return fmt.Errorf("failed to get ExternalProvider %q: %w", ref.Ref.Name, err)
125140
}
126-
return fmt.Errorf("failed to get ExternalProvider %q: %w", ref.Ref.Name, err)
127-
}
128-
129-
if provider.Status.Phase != "Ready" {
130-
return fmt.Errorf("ExternalProvider %q is not ready (phase: %s)", ref.Ref.Name, provider.Status.Phase)
141+
if provider.Status.Phase != "Ready" {
142+
logger.Info("ExternalProvider not ready, skipping", "provider", ref.Ref.Name, "phase", provider.Status.Phase)
143+
skipReasons = append(skipReasons, fmt.Sprintf("ExternalProvider %q is not ready (phase: %s)", ref.Ref.Name, provider.Status.Phase))
144+
continue
145+
}
146+
if _, err := ctrlcommon.ResolvePath(ref.Path, mergeConfig(provider.Spec.Config, ref.Config), ref.TargetModel); err != nil {
147+
logger.Error(err, "path resolution failed, skipping", "provider", ref.Ref.Name, "path", ref.Path)
148+
skipReasons = append(skipReasons, fmt.Sprintf("path %q: %v", ref.Path, err))
149+
continue
150+
}
151+
resolved = append(resolved, resolvedRef{
152+
providerName: provider.Name,
153+
providerEndpoint: provider.Spec.Endpoint,
154+
targetModel: ref.TargetModel,
155+
})
131156
}
132157

133-
if _, err := ctrlcommon.ResolvePath(ref.Path, mergeConfig(provider.Spec.Config, ref.Config), ref.TargetModel); err != nil {
134-
return fmt.Errorf("path %q: %w", ref.Path, err)
158+
if len(resolved) == 0 {
159+
return fmt.Errorf("ExternalModel %q: no provider refs resolved successfully: %s", model.Name, strings.Join(skipReasons, "; "))
135160
}
136161

137162
labels := commonLabels(model.Name)
138163
hr := buildHTTPRoute(
139-
provider.Spec.Endpoint,
140-
provider.Name,
164+
resolved,
141165
model.Name,
142166
model.Namespace,
143167
ctrlcommon.DefaultTLSPort,
@@ -155,10 +179,14 @@ func (r *Reconciler) reconcileHTTPRoute(ctx context.Context, logger logr.Logger,
155179
return fmt.Errorf("failed to apply HTTPRoute: %w", err)
156180
}
157181

182+
providerNames := make([]string, len(resolved))
183+
for i, rr := range resolved {
184+
providerNames[i] = rr.providerName
185+
}
158186
logger.Info("ExternalModel HTTPRoute reconciled",
159187
"httpRoute", model.Name,
160-
"provider", provider.Name,
161-
"targetModel", ref.TargetModel,
188+
"providers", providerNames,
189+
"targetModel", resolved[0].targetModel,
162190
)
163191
return nil
164192
}
@@ -242,39 +270,102 @@ func commonLabels(modelName string) map[string]string {
242270
}
243271
}
244272

245-
func buildHTTPRoute(providerEndpoint, providerName, modelName, namespace string, port int32, gatewayName, gatewayNamespace, routeTimeout string, labels map[string]string) *gatewayapiv1.HTTPRoute {
273+
// selectedProviderHeader is the canonical header name from the plugin package.
274+
const selectedProviderHeader = gatewayapiv1.HTTPHeaderName(providerresolver.SelectedProviderHeader)
275+
276+
func buildHTTPRoute(refs []resolvedRef, modelName, namespace string, port int32, gatewayName, gatewayNamespace, routeTimeout string, labels map[string]string) *gatewayapiv1.HTTPRoute {
246277
gwNamespace := gatewayapiv1.Namespace(gatewayNamespace)
247278
pathType := gatewayapiv1.PathMatchPathPrefix
248279
pathPrefix := "/" + namespace + "/" + modelName
249280
headerType := gatewayapiv1.HeaderMatchExact
250281
gwPort := gatewayapiv1.PortNumber(port)
251282
timeout := gatewayapiv1.Duration(routeTimeout)
252283

253-
backendRefs := []gatewayapiv1.HTTPBackendRef{
254-
{
284+
var rules []gatewayapiv1.HTTPRouteRule
285+
286+
for _, ref := range refs {
287+
backendRefs := []gatewayapiv1.HTTPBackendRef{{
255288
BackendRef: gatewayapiv1.BackendRef{
256289
BackendObjectReference: gatewayapiv1.BackendObjectReference{
257-
Name: gatewayapiv1.ObjectName(providerName),
290+
Name: gatewayapiv1.ObjectName(ref.providerName),
258291
Port: &gwPort,
259292
},
260293
},
261-
},
262-
}
263-
264-
filters := []gatewayapiv1.HTTPRouteFilter{
265-
{
294+
}}
295+
filters := []gatewayapiv1.HTTPRouteFilter{{
266296
Type: gatewayapiv1.HTTPRouteFilterRequestHeaderModifier,
267297
RequestHeaderModifier: &gatewayapiv1.HTTPHeaderFilter{
268298
Set: []gatewayapiv1.HTTPHeader{
269-
{
270-
Name: "Host",
271-
Value: providerEndpoint,
272-
},
299+
{Name: "Host", Value: ref.providerEndpoint},
273300
},
274301
},
275-
},
302+
}}
303+
304+
rules = append(rules, gatewayapiv1.HTTPRouteRule{
305+
Matches: []gatewayapiv1.HTTPRouteMatch{{
306+
Path: &gatewayapiv1.HTTPPathMatch{Type: &pathType, Value: &pathPrefix},
307+
Headers: []gatewayapiv1.HTTPHeaderMatch{{
308+
Name: selectedProviderHeader, Type: &headerType, Value: ref.providerName,
309+
}},
310+
}},
311+
BackendRefs: backendRefs,
312+
Filters: filters,
313+
Timeouts: &gatewayapiv1.HTTPRouteTimeouts{Request: &timeout},
314+
})
315+
316+
rules = append(rules, gatewayapiv1.HTTPRouteRule{
317+
Matches: []gatewayapiv1.HTTPRouteMatch{{
318+
Path: &gatewayapiv1.HTTPPathMatch{Type: &pathType, Value: func() *string { s := "/"; return &s }()},
319+
Headers: []gatewayapiv1.HTTPHeaderMatch{
320+
{Name: "X-Gateway-Model-Name", Type: &headerType, Value: ref.targetModel},
321+
{Name: selectedProviderHeader, Type: &headerType, Value: ref.providerName},
322+
},
323+
}},
324+
BackendRefs: backendRefs,
325+
Filters: filters,
326+
Timeouts: &gatewayapiv1.HTTPRouteTimeouts{Request: &timeout},
327+
})
276328
}
277329

330+
// Fallback rules (no x-ipp-selected-provider): route to refs[0]
331+
fallbackBackendRefs := []gatewayapiv1.HTTPBackendRef{{
332+
BackendRef: gatewayapiv1.BackendRef{
333+
BackendObjectReference: gatewayapiv1.BackendObjectReference{
334+
Name: gatewayapiv1.ObjectName(refs[0].providerName),
335+
Port: &gwPort,
336+
},
337+
},
338+
}}
339+
fallbackFilters := []gatewayapiv1.HTTPRouteFilter{{
340+
Type: gatewayapiv1.HTTPRouteFilterRequestHeaderModifier,
341+
RequestHeaderModifier: &gatewayapiv1.HTTPHeaderFilter{
342+
Set: []gatewayapiv1.HTTPHeader{
343+
{Name: "Host", Value: refs[0].providerEndpoint},
344+
},
345+
},
346+
}}
347+
348+
rules = append(rules,
349+
gatewayapiv1.HTTPRouteRule{
350+
Matches: []gatewayapiv1.HTTPRouteMatch{{
351+
Path: &gatewayapiv1.HTTPPathMatch{Type: &pathType, Value: &pathPrefix},
352+
}},
353+
BackendRefs: fallbackBackendRefs,
354+
Filters: fallbackFilters,
355+
Timeouts: &gatewayapiv1.HTTPRouteTimeouts{Request: &timeout},
356+
},
357+
gatewayapiv1.HTTPRouteRule{
358+
Matches: []gatewayapiv1.HTTPRouteMatch{{
359+
Headers: []gatewayapiv1.HTTPHeaderMatch{
360+
{Name: "X-Gateway-Model-Name", Type: &headerType, Value: refs[0].targetModel},
361+
},
362+
}},
363+
BackendRefs: fallbackBackendRefs,
364+
Filters: fallbackFilters,
365+
Timeouts: &gatewayapiv1.HTTPRouteTimeouts{Request: &timeout},
366+
},
367+
)
368+
278369
return &gatewayapiv1.HTTPRoute{
279370
ObjectMeta: metav1.ObjectMeta{
280371
Name: modelName,
@@ -283,45 +374,12 @@ func buildHTTPRoute(providerEndpoint, providerName, modelName, namespace string,
283374
},
284375
Spec: gatewayapiv1.HTTPRouteSpec{
285376
CommonRouteSpec: gatewayapiv1.CommonRouteSpec{
286-
ParentRefs: []gatewayapiv1.ParentReference{
287-
{
288-
Name: gatewayapiv1.ObjectName(gatewayName),
289-
Namespace: &gwNamespace,
290-
},
291-
},
292-
},
293-
Rules: []gatewayapiv1.HTTPRouteRule{
294-
// TODO: remove path prefix rule when unified entrypoint (RHAISTRAT-1540) is wired.
295-
{
296-
Matches: []gatewayapiv1.HTTPRouteMatch{
297-
{
298-
Path: &gatewayapiv1.HTTPPathMatch{
299-
Type: &pathType,
300-
Value: &pathPrefix,
301-
},
302-
},
303-
},
304-
BackendRefs: backendRefs,
305-
Filters: filters,
306-
Timeouts: &gatewayapiv1.HTTPRouteTimeouts{Request: &timeout},
307-
},
308-
{
309-
Matches: []gatewayapiv1.HTTPRouteMatch{
310-
{
311-
Headers: []gatewayapiv1.HTTPHeaderMatch{
312-
{
313-
Name: "X-Gateway-Model-Name",
314-
Type: &headerType,
315-
Value: modelName,
316-
},
317-
},
318-
},
319-
},
320-
BackendRefs: backendRefs,
321-
Filters: filters,
322-
Timeouts: &gatewayapiv1.HTTPRouteTimeouts{Request: &timeout},
323-
},
377+
ParentRefs: []gatewayapiv1.ParentReference{{
378+
Name: gatewayapiv1.ObjectName(gatewayName),
379+
Namespace: &gwNamespace,
380+
}},
324381
},
382+
Rules: rules,
325383
},
326384
}
327385
}

0 commit comments

Comments
 (0)