Skip to content

Commit eb4ac51

Browse files
committed
chore: support hostname in aigatewayroute
Signed-off-by: xianml <xian@bentoml.com>
1 parent 98200a4 commit eb4ac51

File tree

10 files changed

+269
-8
lines changed

10 files changed

+269
-8
lines changed

api/v1alpha1/ai_gateway_route.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ type AIGatewayRouteSpec struct {
6464
// +optional
6565
ParentRefs []gwapiv1.ParentReference `json:"parentRefs,omitempty"`
6666

67+
// Hostnames defines a set of hostnames that should be matched against the HTTP Host header
68+
// before applying rules. This maps directly to HTTPRoute.Spec.Hostnames.
69+
//
70+
// +optional
71+
// +kubebuilder:validation:MaxItems=16
72+
Hostnames []gwapiv1.Hostname `json:"hostnames,omitempty"`
73+
6774
// Rules is the list of AIGatewayRouteRule that this AIGatewayRoute will match the traffic to.
6875
// Each rule is a subset of the HTTPRoute in the Gateway API (https://gateway-api.sigs.k8s.io/api-types/httproute/).
6976
//

api/v1alpha1/zz_generated.deepcopy.go

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

internal/controller/ai_gateway_route.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,8 @@ func (c *AIGatewayRouteController) newHTTPRoute(ctx context.Context, dst *gwapiv
355355
dst.Annotations[httpRouteAnnotationForAIGatewayGeneratedIndication] = "true"
356356

357357
dst.Spec.ParentRefs = aiGatewayRoute.Spec.ParentRefs
358+
359+
dst.Spec.Hostnames = append(dst.Spec.Hostnames, aiGatewayRoute.Spec.Hostnames...)
358360
return nil
359361
}
360362

internal/controller/ai_gateway_route_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,93 @@ func Test_newHTTPRoute_LabelAndAnnotationPropagation(t *testing.T) {
555555
require.Equal(t, "ann-value-2", httpRoute.Annotations["custom-annotation-2"])
556556
}
557557

558+
func Test_newHTTPRoute_Hostnames_NotSet(t *testing.T) {
559+
c := requireNewFakeClientWithIndexes(t)
560+
561+
// Minimal backend to satisfy newHTTPRoute validation.
562+
backend := &aigv1a1.AIServiceBackend{
563+
ObjectMeta: metav1.ObjectMeta{Name: "test-backend", Namespace: "test-ns"},
564+
Spec: aigv1a1.AIServiceBackendSpec{
565+
BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend", Namespace: ptr.To(gwapiv1.Namespace("test-ns"))},
566+
},
567+
}
568+
require.NoError(t, c.Create(context.Background(), backend))
569+
570+
aiGatewayRoute := &aigv1a1.AIGatewayRoute{
571+
ObjectMeta: metav1.ObjectMeta{
572+
Name: "test-route",
573+
Namespace: "test-ns",
574+
},
575+
Spec: aigv1a1.AIGatewayRouteSpec{
576+
Rules: []aigv1a1.AIGatewayRouteRule{
577+
{
578+
BackendRefs: []aigv1a1.AIGatewayRouteRuleBackendRef{
579+
{Name: "test-backend", Weight: ptr.To[int32](100)},
580+
},
581+
},
582+
},
583+
},
584+
}
585+
586+
controller := &AIGatewayRouteController{client: c}
587+
httpRoute := &gwapiv1.HTTPRoute{
588+
ObjectMeta: metav1.ObjectMeta{
589+
Name: "test-route",
590+
Namespace: "test-ns",
591+
},
592+
}
593+
594+
err := controller.newHTTPRoute(context.Background(), httpRoute, aiGatewayRoute)
595+
require.NoError(t, err)
596+
597+
// Without spec.hostnames, Hostnames should remain empty.
598+
require.Empty(t, httpRoute.Spec.Hostnames)
599+
}
600+
601+
func Test_newHTTPRoute_Hostnames_Set(t *testing.T) {
602+
c := requireNewFakeClientWithIndexes(t)
603+
604+
// Minimal backend to satisfy newHTTPRoute validation.
605+
backend := &aigv1a1.AIServiceBackend{
606+
ObjectMeta: metav1.ObjectMeta{Name: "test-backend", Namespace: "test-ns"},
607+
Spec: aigv1a1.AIServiceBackendSpec{
608+
BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend", Namespace: ptr.To(gwapiv1.Namespace("test-ns"))},
609+
},
610+
}
611+
require.NoError(t, c.Create(context.Background(), backend))
612+
613+
aiGatewayRoute := &aigv1a1.AIGatewayRoute{
614+
ObjectMeta: metav1.ObjectMeta{
615+
Name: "test-route",
616+
Namespace: "test-ns",
617+
},
618+
Spec: aigv1a1.AIGatewayRouteSpec{
619+
Hostnames: []gwapiv1.Hostname{"api.example.com", "*.example.net", "sub.example.com"},
620+
Rules: []aigv1a1.AIGatewayRouteRule{
621+
{
622+
BackendRefs: []aigv1a1.AIGatewayRouteRuleBackendRef{
623+
{Name: "test-backend", Weight: ptr.To[int32](100)},
624+
},
625+
},
626+
},
627+
},
628+
}
629+
630+
controller := &AIGatewayRouteController{client: c, logger: logr.Discard()}
631+
httpRoute := &gwapiv1.HTTPRoute{
632+
ObjectMeta: metav1.ObjectMeta{
633+
Name: "test-route",
634+
Namespace: "test-ns",
635+
},
636+
}
637+
638+
err := controller.newHTTPRoute(context.Background(), httpRoute, aiGatewayRoute)
639+
require.NoError(t, err)
640+
641+
expected := []gwapiv1.Hostname{"api.example.com", "*.example.net", "sub.example.com"}
642+
require.Equal(t, expected, httpRoute.Spec.Hostnames)
643+
}
644+
558645
func TestAIGatewayRouteController_syncGateways_NamespaceDetermination(t *testing.T) {
559646
fakeClient := requireNewFakeClientWithIndexes(t)
560647
eventCh := internaltesting.NewControllerEventChan[*gwapiv1.Gateway]()

internal/controller/gateway.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ func (c *GatewayController) reconcileFilterConfigSecret(
318318
continue
319319
}
320320
hasEffectiveRoute = true
321+
hostnames := aiGatewayRoute.Spec.Hostnames
321322
spec := aiGatewayRoute.Spec
322323
for ruleIndex := range spec.Rules {
323324
rule := &spec.Rules[ruleIndex]
@@ -330,11 +331,20 @@ func (c *GatewayController) reconcileFilterConfigSecret(
330331
if (h.Type != nil && *h.Type != gwapiv1.HeaderMatchExact) || string(h.Name) != internalapi.ModelNameHeaderKeyDefault {
331332
continue
332333
}
333-
ec.Models = append(ec.Models, filterapi.Model{
334+
model := filterapi.Model{
334335
Name: h.Value,
335336
CreatedAt: ptr.Deref[metav1.Time](rule.ModelsCreatedAt, aiGatewayRoute.CreationTimestamp).UTC(),
336337
OwnedBy: ptr.Deref(rule.ModelsOwnedBy, defaultOwnedBy),
337-
})
338+
}
339+
ec.Models = append(ec.Models, model)
340+
if len(hostnames) > 0 {
341+
if ec.ModelsByHost == nil {
342+
ec.ModelsByHost = make(map[string][]filterapi.Model)
343+
}
344+
for _, hn := range hostnames {
345+
ec.ModelsByHost[string(hn)] = append(ec.ModelsByHost[string(hn)], model)
346+
}
347+
}
338348
}
339349
}
340350
for backendRefIndex := range rule.BackendRefs {

internal/extproc/models_processor.go

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"context"
1010
"fmt"
1111
"log/slog"
12+
"strings"
1213

1314
corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
1415
extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
@@ -34,23 +35,27 @@ type modelsProcessor struct {
3435
var _ Processor = (*modelsProcessor)(nil)
3536

3637
// NewModelsProcessor creates a new processor that returns the list of declared models.
37-
func NewModelsProcessor(config *filterapi.RuntimeConfig, _ map[string]string, logger *slog.Logger, isUpstreamFilter bool, _ bool) (Processor, error) {
38+
func NewModelsProcessor(config *filterapi.RuntimeConfig, requestHeaders map[string]string, logger *slog.Logger, isUpstreamFilter bool, _ bool) (Processor, error) {
3839
if isUpstreamFilter {
3940
return passThroughProcessor{}, nil
4041
}
41-
models := openai.ModelList{
42+
43+
host := requestHost(requestHeaders)
44+
selectedModels := selectModelsForHost(host, config)
45+
46+
modelList := openai.ModelList{
4247
Object: "list",
43-
Data: make([]openai.Model, 0, len(config.DeclaredModels)),
48+
Data: make([]openai.Model, 0, len(selectedModels)),
4449
}
45-
for _, m := range config.DeclaredModels {
46-
models.Data = append(models.Data, openai.Model{
50+
for _, m := range selectedModels {
51+
modelList.Data = append(modelList.Data, openai.Model{
4752
ID: m.Name,
4853
Object: "model",
4954
OwnedBy: m.OwnedBy,
5055
Created: openai.JSONUNIXTime(m.CreatedAt),
5156
})
5257
}
53-
return &modelsProcessor{logger: logger, models: models}, nil
58+
return &modelsProcessor{logger: logger.With("host", host), models: modelList}, nil
5459
}
5560

5661
// ProcessRequestHeaders implements [Processor.ProcessRequestHeaders].
@@ -84,3 +89,51 @@ func setHeader(headers *extprocv3.HeaderMutation, key, value string) {
8489
},
8590
})
8691
}
92+
93+
// requestHost normalizes the host/authority header for matching (lowercases and strips port).
94+
func requestHost(headers map[string]string) string {
95+
host := headers[":authority"]
96+
if host == "" {
97+
host = headers["host"]
98+
}
99+
if host == "" {
100+
return ""
101+
}
102+
if idx := strings.IndexByte(host, ':'); idx != -1 {
103+
host = host[:idx]
104+
}
105+
return strings.ToLower(host)
106+
}
107+
108+
// selectModelsForHost returns the models for the given host, falling back to the global list.
109+
func selectModelsForHost(host string, cfg *filterapi.RuntimeConfig) []filterapi.Model {
110+
if host == "" || len(cfg.ModelsByHost) == 0 {
111+
return cfg.DeclaredModels
112+
}
113+
114+
if exact, ok := cfg.ModelsByHost[host]; ok {
115+
return exact
116+
}
117+
118+
bestMatchLength := -1
119+
var bestMatchModels []filterapi.Model
120+
for pattern, models := range cfg.ModelsByHost {
121+
if !strings.HasPrefix(pattern, "*.") {
122+
continue
123+
}
124+
suffix := strings.TrimPrefix(pattern, "*.")
125+
// Wildcard hostnames only match subdomains with a label boundary.
126+
// Example: "*.bentoml.com" matches "api.bentoml.com" but not "bentoml.com" or "evilbentoml.com".
127+
if strings.HasSuffix(host, "."+suffix) {
128+
if len(suffix) > bestMatchLength {
129+
bestMatchLength = len(suffix)
130+
bestMatchModels = models
131+
}
132+
}
133+
}
134+
if bestMatchModels != nil {
135+
return bestMatchModels
136+
}
137+
138+
return []filterapi.Model{}
139+
}

internal/extproc/models_processor_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,68 @@ func headers(in []*corev3.HeaderValueOption) map[string]string {
6868
}
6969
return h
7070
}
71+
72+
func Test_selectModelsForHost(t *testing.T) {
73+
defaultModels := []filterapi.Model{{Name: "default"}}
74+
exactModels := []filterapi.Model{{Name: "exact"}}
75+
wildcardComModels := []filterapi.Model{{Name: "wildcard-com"}}
76+
wildcardBentomlModels := []filterapi.Model{{Name: "wildcard-bentoml"}}
77+
78+
cfg := &filterapi.RuntimeConfig{
79+
DeclaredModels: defaultModels,
80+
ModelsByHost: map[string][]filterapi.Model{
81+
"api.bentoml.com": exactModels,
82+
"*.com": wildcardComModels,
83+
"*.bentoml.com": wildcardBentomlModels,
84+
"not-a-pattern.com": {{Name: "ignored"}},
85+
},
86+
}
87+
88+
tests := []struct {
89+
name string
90+
host string
91+
want []filterapi.Model
92+
}{
93+
{
94+
name: "exact match wins",
95+
host: "api.bentoml.com",
96+
want: exactModels,
97+
},
98+
{
99+
name: "wildcard match with label boundary",
100+
host: "chat.bentoml.com",
101+
want: wildcardBentomlModels,
102+
},
103+
{
104+
name: "more specific wildcard wins",
105+
host: "foo.bentoml.com",
106+
want: wildcardBentomlModels,
107+
},
108+
{
109+
name: "wildcard does not match apex",
110+
host: "bentoml.com",
111+
want: wildcardComModels,
112+
},
113+
{
114+
name: "wildcard does not match missing boundary",
115+
host: "evilbentoml.com",
116+
want: wildcardComModels,
117+
},
118+
{
119+
name: "fallback to default when no match",
120+
host: "localhost",
121+
want: []filterapi.Model{},
122+
},
123+
{
124+
name: "empty host falls back to default",
125+
host: "",
126+
want: defaultModels,
127+
},
128+
}
129+
130+
for _, tt := range tests {
131+
t.Run(tt.name, func(t *testing.T) {
132+
require.Equal(t, tt.want, selectModelsForHost(tt.host, cfg))
133+
})
134+
}
135+
}

internal/filterapi/filterconfig.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ type Config struct {
3939
Backends []Backend `json:"backends,omitempty"`
4040
// Models is the list of models that this route is aware of. Used to populate the "/models" endpoint in OpenAI-compatible APIs.
4141
Models []Model `json:"models,omitempty"`
42+
// ModelsByHost is the list of models keyed by hostname. When present, the extproc "/v1/models" processor will prefer
43+
// the hostname-specific list over the global Models slice.
44+
ModelsByHost map[string][]Model `json:"modelsByHost,omitempty"`
4245
// MCPConfig is the configuration for the MCPRoute implementations.
4346
MCPConfig *MCPConfig `json:"mcpConfig,omitempty"`
4447
}

internal/filterapi/runtime.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ type RuntimeConfig struct {
3333
RequestCosts []RuntimeRequestCost
3434
// DeclaredModels is the list of declared models.
3535
DeclaredModels []Model
36+
// ModelsByHost maps hostnames to their specific model lists for per-host filtering.
37+
ModelsByHost map[string][]Model
3638
// Backends is the map of backends by name.
3739
Backends map[string]*RuntimeBackend
3840
}
@@ -87,5 +89,6 @@ func NewRuntimeConfig(ctx context.Context, config *Config, fn NewBackendAuthHand
8789
Backends: backends,
8890
RequestCosts: costs,
8991
DeclaredModels: config.Models,
92+
ModelsByHost: config.ModelsByHost,
9093
}, nil
9194
}

manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_aigatewayroutes.yaml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,32 @@ spec:
6868
spec:
6969
description: Spec defines the details of the AIGatewayRoute.
7070
properties:
71+
hostnames:
72+
description: |-
73+
Hostnames defines a set of hostnames that should be matched against the HTTP Host header
74+
before applying rules. This maps directly to HTTPRoute.Spec.Hostnames.
75+
items:
76+
description: |-
77+
Hostname is the fully qualified domain name of a network host. This matches
78+
the RFC 1123 definition of a hostname with 2 notable exceptions:
79+
80+
1. IPs are not allowed.
81+
2. A hostname may be prefixed with a wildcard label (`*.`). The wildcard
82+
label must appear by itself as the first label.
83+
84+
Hostname can be "precise" which is a domain name without the terminating
85+
dot of a network host (e.g. "foo.example.com") or "wildcard", which is a
86+
domain name prefixed with a single wildcard label (e.g. `*.example.com`).
87+
88+
Note that as per RFC1035 and RFC1123, a *label* must consist of lower case
89+
alphanumeric characters or '-', and must start and end with an alphanumeric
90+
character. No other punctuation is allowed.
91+
maxLength: 253
92+
minLength: 1
93+
pattern: ^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
94+
type: string
95+
maxItems: 16
96+
type: array
7197
llmRequestCosts:
7298
description: "LLMRequestCosts specifies how to capture the cost of
7399
the LLM-related request, notably the token usage.\nThe AI Gateway

0 commit comments

Comments
 (0)