Skip to content

Commit f4dbf71

Browse files
authored
Merge branch 'main' into pr/inferencepool-backendref
2 parents 08f15ea + 3db0895 commit f4dbf71

34 files changed

Lines changed: 1134 additions & 61 deletions

api/v1alpha1/ai_gateway_route.go

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

67+
// Hostnames is a list of hostnames matched against the HTTP Host header to select an AIGatewayRoute
68+
// used to process the request. This is equivalent to the Hostnames field in the Gateway API HTTPRouteSpec.
69+
// When specified, the generated HTTPRoute will include these hostnames for hostname-based filtering.
70+
// See https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.HTTPRouteSpec
71+
// for the details of the Hostnames field in the Gateway API.
72+
//
73+
// +optional
74+
// +kubebuilder:validation:MaxItems=16
75+
Hostnames []gwapiv1.Hostname `json:"hostnames,omitempty"`
76+
6777
// Rules is the list of AIGatewayRouteRule that this AIGatewayRoute will match the traffic to.
6878
// Each rule is a subset of the HTTPRoute in the Gateway API (https://gateway-api.sigs.k8s.io/api-types/httproute/).
6979
//

api/v1alpha1/shared_types.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ type VersionedAPISchema struct {
2222
//
2323
// When the name is set to AzureOpenAI, this version maps to "API Version" in the
2424
// Azure OpenAI API documentation (https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#rest-api-versioning).
25+
// This field is ignored for OpenAI, AWSBedrock, GCPVertexAI, and Anthropic.
26+
// For OpenAI and Anthropic, use prefix to configure custom request paths.
2527
//
2628
// See https://aigateway.envoyproxy.io/docs/capabilities/llm-integrations/supported-providers for details.
2729
// +optional
@@ -30,9 +32,11 @@ type VersionedAPISchema struct {
3032
// Prefix is the prefix for the API.
3133
//
3234
// When the name is set to "OpenAI", "chat completions" API endpoint will be "${this_field}/chat/completions".
35+
// When the name is set to "Anthropic", the "messages" API endpoint will be "${this_field}/messages".
3336
// It can be with or without a leading slash ("/").
37+
// This field is ignored for AWSAnthropic and GCPAnthropic.
3438
//
35-
// This is especially useful when routing to the backend that has an OpenAI compatible API but has a different
39+
// This is especially useful when routing to a backend that has an OpenAI or Anthropic compatible API but has a different
3640
// prefix. For example, Gemini OpenAI compatible API (https://ai.google.dev/gemini-api/docs/openai) uses
3741
// "/v1beta/openai" prefix. Another example is that Cohere AI (https://docs.cohere.com/v2/docs/compatibility-api)
3842
// uses "/compatibility/v1" prefix. On the other hand, DeepSeek (https://api-docs.deepseek.com/) doesn't

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.

api/v1beta1/ai_gateway_route.go

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

67+
// Hostnames is a list of hostnames matched against the HTTP Host header to select an AIGatewayRoute
68+
// used to process the request. This is equivalent to the Hostnames field in the Gateway API HTTPRouteSpec.
69+
// When specified, the generated HTTPRoute will include these hostnames for hostname-based filtering.
70+
// See https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.HTTPRouteSpec
71+
// for the details of the Hostnames field in the Gateway API.
72+
//
73+
// +optional
74+
// +kubebuilder:validation:MaxItems=16
75+
Hostnames []gwapiv1.Hostname `json:"hostnames,omitempty"`
76+
6777
// Rules is the list of AIGatewayRouteRule that this AIGatewayRoute will match the traffic to.
6878
// Each rule is a subset of the HTTPRoute in the Gateway API (https://gateway-api.sigs.k8s.io/api-types/httproute/).
6979
//

api/v1beta1/shared_types.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ type VersionedAPISchema struct {
2626
//
2727
// When the name is set to AzureOpenAI, this version maps to "API Version" in the
2828
// Azure OpenAI API documentation (https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#rest-api-versioning).
29+
// This field is ignored for OpenAI, AWSBedrock, GCPVertexAI, and Anthropic.
30+
// For OpenAI and Anthropic, use prefix to configure custom request paths.
2931
//
3032
// See https://aigateway.envoyproxy.io/docs/capabilities/llm-integrations/supported-providers for details.
3133
// +optional
@@ -34,9 +36,11 @@ type VersionedAPISchema struct {
3436
// Prefix is the prefix for the API.
3537
//
3638
// When the name is set to "OpenAI", "chat completions" API endpoint will be "${this_field}/chat/completions".
39+
// When the name is set to "Anthropic", the "messages" API endpoint will be "${this_field}/messages".
3740
// It can be with or without a leading slash ("/").
41+
// This field is ignored for AWSAnthropic and GCPAnthropic.
3842
//
39-
// This is especially useful when routing to the backend that has an OpenAI compatible API but has a different
43+
// This is especially useful when routing to a backend that has an OpenAI or Anthropic compatible API but has a different
4044
// prefix. For example, Gemini OpenAI compatible API (https://ai.google.dev/gemini-api/docs/openai) uses
4145
// "/v1beta/openai" prefix. Another example is that Cohere AI (https://docs.cohere.com/v2/docs/compatibility-api)
4246
// uses "/compatibility/v1" prefix. On the other hand, DeepSeek (https://api-docs.deepseek.com/) doesn't

api/v1beta1/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 = aiGatewayRoute.Spec.Hostnames
358360
return nil
359361
}
360362

internal/controller/ai_gateway_route_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,90 @@ 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+
backend := &aigv1b1.AIServiceBackend{
562+
ObjectMeta: metav1.ObjectMeta{Name: "test-backend", Namespace: "test-ns"},
563+
Spec: aigv1b1.AIServiceBackendSpec{
564+
BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend", Namespace: ptr.To(gwapiv1.Namespace("test-ns"))},
565+
},
566+
}
567+
require.NoError(t, c.Create(context.Background(), backend))
568+
569+
aiGatewayRoute := &aigv1b1.AIGatewayRoute{
570+
ObjectMeta: metav1.ObjectMeta{
571+
Name: "test-route",
572+
Namespace: "test-ns",
573+
},
574+
Spec: aigv1b1.AIGatewayRouteSpec{
575+
Rules: []aigv1b1.AIGatewayRouteRule{
576+
{
577+
BackendRefs: []aigv1b1.AIGatewayRouteRuleBackendRef{
578+
{Name: "test-backend", Weight: ptr.To[int32](100)},
579+
},
580+
},
581+
},
582+
},
583+
}
584+
585+
controller := &AIGatewayRouteController{client: c}
586+
httpRoute := &gwapiv1.HTTPRoute{
587+
ObjectMeta: metav1.ObjectMeta{
588+
Name: "test-route",
589+
Namespace: "test-ns",
590+
},
591+
}
592+
593+
err := controller.newHTTPRoute(context.Background(), httpRoute, aiGatewayRoute)
594+
require.NoError(t, err)
595+
596+
require.Empty(t, httpRoute.Spec.Hostnames)
597+
}
598+
599+
func Test_newHTTPRoute_Hostnames_Set(t *testing.T) {
600+
c := requireNewFakeClientWithIndexes(t)
601+
602+
backend := &aigv1b1.AIServiceBackend{
603+
ObjectMeta: metav1.ObjectMeta{Name: "test-backend", Namespace: "test-ns"},
604+
Spec: aigv1b1.AIServiceBackendSpec{
605+
BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend", Namespace: ptr.To(gwapiv1.Namespace("test-ns"))},
606+
},
607+
}
608+
require.NoError(t, c.Create(context.Background(), backend))
609+
610+
aiGatewayRoute := &aigv1b1.AIGatewayRoute{
611+
ObjectMeta: metav1.ObjectMeta{
612+
Name: "test-route",
613+
Namespace: "test-ns",
614+
},
615+
Spec: aigv1b1.AIGatewayRouteSpec{
616+
Hostnames: []gwapiv1.Hostname{"api.example.com", "*.example.net", "sub.example.com"},
617+
Rules: []aigv1b1.AIGatewayRouteRule{
618+
{
619+
BackendRefs: []aigv1b1.AIGatewayRouteRuleBackendRef{
620+
{Name: "test-backend", Weight: ptr.To[int32](100)},
621+
},
622+
},
623+
},
624+
},
625+
}
626+
627+
controller := &AIGatewayRouteController{client: c, logger: logr.Discard()}
628+
httpRoute := &gwapiv1.HTTPRoute{
629+
ObjectMeta: metav1.ObjectMeta{
630+
Name: "test-route",
631+
Namespace: "test-ns",
632+
},
633+
}
634+
635+
err := controller.newHTTPRoute(context.Background(), httpRoute, aiGatewayRoute)
636+
require.NoError(t, err)
637+
638+
expected := []gwapiv1.Hostname{"api.example.com", "*.example.net", "sub.example.com"}
639+
require.Equal(t, expected, httpRoute.Spec.Hostnames)
640+
}
641+
558642
func TestAIGatewayRouteController_syncGateways_NamespaceDetermination(t *testing.T) {
559643
fakeClient := requireNewFakeClientWithIndexes(t)
560644
eventCh := internaltesting.NewControllerEventChan[*gwapiv1.Gateway]()

internal/controller/gateway.go

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ func (c *GatewayController) Reconcile(ctx context.Context, req ctrl.Request) (ct
164164
func schemaToFilterAPI(schema aigv1b1.VersionedAPISchema) filterapi.VersionedAPISchema {
165165
ret := filterapi.VersionedAPISchema{}
166166
ret.Name = filterapi.APISchemaName(schema.Name)
167-
if schema.Name == aigv1b1.APISchemaOpenAI {
167+
if schema.Name == aigv1b1.APISchemaOpenAI || schema.Name == aigv1b1.APISchemaAnthropic {
168168
ret.Prefix = cmp.Or(ptr.Deref(schema.Prefix, ""), "v1")
169169
} else {
170170
ret.Version = ptr.Deref(schema.Version, "")
@@ -367,6 +367,11 @@ func (c *GatewayController) reconcileFilterConfigSecret(
367367
ec.GlobalLLMRequestCosts = append(ec.GlobalLLMRequestCosts, fc)
368368
}
369369

370+
// Models contributed by routes with no Spec.Hostnames. We only promote these to
371+
// ec.UnscopedModels (and merge them into ec.ModelsByHost) when at least one route
372+
// IS hostname-scoped; otherwise the existing ec.Models list already covers them.
373+
var unscopedModels []filterapi.Model
374+
370375
for i := range aiGatewayRoutes {
371376
aiGatewayRoute := &aiGatewayRoutes[i]
372377
if !aiGatewayRoute.GetDeletionTimestamp().IsZero() {
@@ -375,6 +380,7 @@ func (c *GatewayController) reconcileFilterConfigSecret(
375380
}
376381
hasEffectiveRoute = true
377382
routeName := fmt.Sprintf("%s/%s", aiGatewayRoute.Namespace, aiGatewayRoute.Name)
383+
hostnames := aiGatewayRoute.Spec.Hostnames
378384
spec := aiGatewayRoute.Spec
379385
routeBackendNamesSet := map[string]struct{}{}
380386
routeBackendNames := []string{}
@@ -389,11 +395,25 @@ func (c *GatewayController) reconcileFilterConfigSecret(
389395
if (h.Type != nil && *h.Type != gwapiv1.HeaderMatchExact) || string(h.Name) != internalapi.ModelNameHeaderKeyDefault {
390396
continue
391397
}
392-
ec.Models = append(ec.Models, filterapi.Model{
398+
model := filterapi.Model{
393399
Name: h.Value,
394400
CreatedAt: ptr.Deref[metav1.Time](rule.ModelsCreatedAt, aiGatewayRoute.CreationTimestamp).UTC(),
395401
OwnedBy: ptr.Deref(rule.ModelsOwnedBy, defaultOwnedBy),
396-
})
402+
}
403+
ec.Models = append(ec.Models, model)
404+
if len(hostnames) > 0 {
405+
if ec.ModelsByHost == nil {
406+
ec.ModelsByHost = make(map[string][]filterapi.Model)
407+
}
408+
for _, hn := range hostnames {
409+
ec.ModelsByHost[string(hn)] = append(ec.ModelsByHost[string(hn)], model)
410+
}
411+
} else {
412+
// Routes without hostnames are "unscoped": they apply to every host.
413+
// Tracked in unscopedModels for now; only promoted to ec.UnscopedModels
414+
// after the loop if at least one scoped route is also present.
415+
unscopedModels = append(unscopedModels, model)
416+
}
397417
}
398418
}
399419
for backendRefIndex := range rule.BackendRefs {
@@ -483,6 +503,18 @@ func (c *GatewayController) reconcileFilterConfigSecret(
483503
}
484504
}
485505

506+
// If at least one route is hostname-scoped, promote the unscoped models to ec.UnscopedModels
507+
// so the runtime can fall back to them on unmatched hosts, and merge them into every per-host
508+
// list so a host-matched request still sees the models from routes that didn't declare hostnames.
509+
// When no route uses hostname scoping, ec.Models is the sole source of truth and we skip both
510+
// steps to avoid serializing a redundant UnscopedModels duplicate of Models.
511+
if len(ec.ModelsByHost) > 0 && len(unscopedModels) > 0 {
512+
ec.UnscopedModels = unscopedModels
513+
for hn := range ec.ModelsByHost {
514+
ec.ModelsByHost[hn] = append(ec.ModelsByHost[hn], unscopedModels...)
515+
}
516+
}
517+
486518
// Configuration for MCP processor.
487519
var effectiveMCPRoute bool
488520
ec.MCPConfig, effectiveMCPRoute = mcpConfig(mcpRoutes)

0 commit comments

Comments
 (0)