Skip to content
8 changes: 8 additions & 0 deletions api/v1alpha1/ai_gateway_route.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,14 @@ type AIGatewayRouteRuleBackendRef struct {
// +optional
ModelNameOverride string `json:"modelNameOverride,omitempty"`

// HeaderMutation defines the request header mutation to be applied to this backend.
// When both route-level and backend-level HeaderMutation are defined,
// route-level takes precedence over backend-level for conflicting operations.
// This field is ignored when referencing InferencePool resources.
//
// +optional
HeaderMutation *HTTPHeaderMutation `json:"headerMutation,omitempty"`

// Weight is the weight of the backend. This is exactly the same as the weight in
// the BackendRef in the Gateway API. See for the details:
// https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io%2fv1.BackendRef
Expand Down
5 changes: 5 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

58 changes: 57 additions & 1 deletion internal/controller/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,53 @@ func headerMutationToFilterAPI(m *aigv1a1.HTTPHeaderMutation) *filterapi.HTTPHea
return ret
}

// mergeHeaderMutations merges route-level and backend-level HeaderMutation with route-level taking precedence.
// Returns the merged HeaderMutation where route-level operations override backend-level operations for conflicting headers.
func mergeHeaderMutations(routeLevel, backendLevel *aigv1a1.HTTPHeaderMutation) *aigv1a1.HTTPHeaderMutation {
if routeLevel == nil {
return backendLevel
}
if backendLevel == nil {
return routeLevel
}

result := &aigv1a1.HTTPHeaderMutation{}

// Merge Set operations (route-level wins conflicts)
headerMap := make(map[string]gwapiv1.HTTPHeader)

// Add backend-level headers first
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious, why add comments for the Set logic but not the Remove? Also, add periods at the end of the comments.

for _, h := range backendLevel.Set {
headerMap[strings.ToLower(string(h.Name))] = h
}

// Override with route-level headers (route-level wins)
for _, h := range routeLevel.Set {
headerMap[strings.ToLower(string(h.Name))] = h
}

// Convert back to slice
for _, h := range headerMap {
result.Set = append(result.Set, h)
}

// Merge Remove operations (combine and deduplicate)
removeMap := make(map[string]bool)

for _, h := range backendLevel.Remove {
removeMap[strings.ToLower(h)] = true
}
for _, h := range routeLevel.Remove {
removeMap[strings.ToLower(h)] = true
}

for h := range removeMap {
result.Remove = append(result.Remove, h)
}

return result
}

// reconcileFilterConfigSecret updates the filter config secret for the external processor.
func (c *GatewayController) reconcileFilterConfigSecret(
ctx context.Context,
Expand Down Expand Up @@ -238,7 +285,16 @@ func (c *GatewayController) reconcileFilterConfigSecret(
"namespace", backendNamespace)
continue
}
b.HeaderMutation = headerMutationToFilterAPI(backendObj.Spec.HeaderMutation)

// Extract HeaderMutation from both route and backend levels
routeHeaderMutation := backendRef.HeaderMutation
backendHeaderMutation := backendObj.Spec.HeaderMutation

// Merge with route-level taking precedence over backend-level
mergedHeaderMutation := mergeHeaderMutations(routeHeaderMutation, backendHeaderMutation)

// Convert to FilterAPI format
b.HeaderMutation = headerMutationToFilterAPI(mergedHeaderMutation)
b.Schema = schemaToFilterAPI(backendObj.Spec.APISchema)
if bsp != nil {
b.Auth, err = c.bspToFilterAPIBackendAuth(ctx, bsp)
Expand Down
157 changes: 157 additions & 0 deletions internal/controller/gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"testing"
"time"

"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/stretchr/testify/require"
"go.uber.org/zap/zapcore"
appsv1 "k8s.io/api/apps/v1"
Expand Down Expand Up @@ -1013,3 +1015,158 @@ func TestGatewayController_reconcileFilterMCPConfigSecret(t *testing.T) {
require.NotNil(t, fc.MCPConfig)
require.Equal(t, "http://127.0.0.1:"+strconv.Itoa(internalapi.MCPBackendListenerPort), fc.MCPConfig.BackendListenerAddr)
}

func Test_mergeHeaderMutations(t *testing.T) {
tests := []struct {
name string
routeLevel *aigv1a1.HTTPHeaderMutation
backendLevel *aigv1a1.HTTPHeaderMutation
expected *aigv1a1.HTTPHeaderMutation
}{
{
name: "both nil",
routeLevel: nil,
backendLevel: nil,
expected: nil,
},
{
name: "route nil, backend has values",
routeLevel: nil,
backendLevel: &aigv1a1.HTTPHeaderMutation{
Set: []gwapiv1.HTTPHeader{{Name: "Backend-Header", Value: "backend-value"}},
Remove: []string{"Backend-Remove"},
},
expected: &aigv1a1.HTTPHeaderMutation{
Set: []gwapiv1.HTTPHeader{{Name: "Backend-Header", Value: "backend-value"}},
Remove: []string{"Backend-Remove"},
},
},
{
name: "route has values, backend nil",
routeLevel: &aigv1a1.HTTPHeaderMutation{
Set: []gwapiv1.HTTPHeader{{Name: "Route-Header", Value: "route-value"}},
Remove: []string{"Route-Remove"},
},
backendLevel: nil,
expected: &aigv1a1.HTTPHeaderMutation{
Set: []gwapiv1.HTTPHeader{{Name: "Route-Header", Value: "route-value"}},
Remove: []string{"Route-Remove"},
},
},
{
name: "no conflicts - different headers",
routeLevel: &aigv1a1.HTTPHeaderMutation{
Set: []gwapiv1.HTTPHeader{{Name: "Route-Header", Value: "route-value"}},
Remove: []string{"Route-Remove"},
},
backendLevel: &aigv1a1.HTTPHeaderMutation{
Set: []gwapiv1.HTTPHeader{{Name: "Backend-Header", Value: "backend-value"}},
Remove: []string{"Backend-Remove"},
},
expected: &aigv1a1.HTTPHeaderMutation{
Set: []gwapiv1.HTTPHeader{
{Name: "Backend-Header", Value: "backend-value"},
{Name: "Route-Header", Value: "route-value"},
},
Remove: []string{"backend-remove", "route-remove"},
},
},
{
name: "route overrides backend for same header name",
routeLevel: &aigv1a1.HTTPHeaderMutation{
Set: []gwapiv1.HTTPHeader{{Name: "X-Custom", Value: "route-value"}},
},
backendLevel: &aigv1a1.HTTPHeaderMutation{
Set: []gwapiv1.HTTPHeader{{Name: "X-Custom", Value: "backend-value"}},
},
expected: &aigv1a1.HTTPHeaderMutation{
Set: []gwapiv1.HTTPHeader{{Name: "X-Custom", Value: "route-value"}},
},
},
{
name: "case insensitive header name conflicts",
routeLevel: &aigv1a1.HTTPHeaderMutation{
Set: []gwapiv1.HTTPHeader{{Name: "x-custom", Value: "route-value"}},
},
backendLevel: &aigv1a1.HTTPHeaderMutation{
Set: []gwapiv1.HTTPHeader{{Name: "X-CUSTOM", Value: "backend-value"}},
},
expected: &aigv1a1.HTTPHeaderMutation{
Set: []gwapiv1.HTTPHeader{{Name: "x-custom", Value: "route-value"}},
},
},
{
name: "remove operations are combined and deduplicated",
routeLevel: &aigv1a1.HTTPHeaderMutation{
Remove: []string{"X-Remove", "x-shared"},
},
backendLevel: &aigv1a1.HTTPHeaderMutation{
Remove: []string{"X-Backend-Remove", "X-SHARED"},
},
expected: &aigv1a1.HTTPHeaderMutation{
Remove: []string{"x-backend-remove", "x-remove", "x-shared"},
},
},
{
name: "complex merge scenario",
routeLevel: &aigv1a1.HTTPHeaderMutation{
Set: []gwapiv1.HTTPHeader{
{Name: "X-Route-Only", Value: "route-only"},
{Name: "X-Override", Value: "route-wins"},
},
Remove: []string{"X-Route-Remove", "x-shared-remove"},
},
backendLevel: &aigv1a1.HTTPHeaderMutation{
Set: []gwapiv1.HTTPHeader{
{Name: "X-Backend-Only", Value: "backend-only"},
{Name: "x-override", Value: "backend-loses"},
},
Remove: []string{"X-Backend-Remove", "X-SHARED-REMOVE"},
},
expected: &aigv1a1.HTTPHeaderMutation{
Set: []gwapiv1.HTTPHeader{
{Name: "X-Backend-Only", Value: "backend-only"},
{Name: "X-Override", Value: "route-wins"},
{Name: "X-Route-Only", Value: "route-only"},
},
Remove: []string{"x-backend-remove", "x-route-remove", "x-shared-remove"},
},
},
{
name: "empty mutations",
routeLevel: &aigv1a1.HTTPHeaderMutation{
Set: []gwapiv1.HTTPHeader{},
Remove: []string{},
},
backendLevel: &aigv1a1.HTTPHeaderMutation{
Set: []gwapiv1.HTTPHeader{},
Remove: []string{},
},
expected: &aigv1a1.HTTPHeaderMutation{
Set: nil,
Remove: nil,
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := mergeHeaderMutations(tt.routeLevel, tt.backendLevel)

if tt.expected == nil {
require.Nil(t, result)
return
}

require.NotNil(t, result)

if d := cmp.Diff(tt.expected, result, cmpopts.SortSlices(func(a, b gwapiv1.HTTPHeader) bool {
return a.Name < b.Name
}), cmpopts.SortSlices(func(a, b string) bool {
return a < b
})); d != "" {
t.Errorf("mergeHeaderMutations() mismatch (-expected +got):\n%s", d)
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,88 @@ spec:
maxLength: 253
pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
type: string
headerMutation:
description: |-
HeaderMutation defines the request header mutation to be applied to this backend.
When both route-level and backend-level HeaderMutation are defined,
route-level takes precedence over backend-level for conflicting operations.
This field is ignored when referencing InferencePool resources.
properties:
remove:
description: |-
Remove the given header(s) from the HTTP request before the action. The
value of Remove is a list of HTTP header names. Note that the header
names are case-insensitive (see
https://datatracker.ietf.org/doc/html/rfc2616#section-4.2).

Input:
GET /foo HTTP/1.1
my-header1: foo
my-header2: bar
my-header3: baz

Config:
remove: ["my-header1", "my-header3"]

Output:
GET /foo HTTP/1.1
my-header2: bar
items:
type: string
maxItems: 16
type: array
x-kubernetes-list-type: set
set:
description: |-
Set overwrites/adds the request with the given header (name, value)
before the action.

Input:
GET /foo HTTP/1.1
my-header: foo

Config:
set:
- name: "my-header"
value: "bar"

Output:
GET /foo HTTP/1.1
my-header: bar
items:
description: HTTPHeader represents an HTTP Header
name and value as defined by RFC 7230.
properties:
name:
description: |-
Name is the name of the HTTP Header to be matched. Name matching MUST be
case-insensitive. (See https://tools.ietf.org/html/rfc7230#section-3.2).

If multiple entries specify equivalent header names, the first entry with
an equivalent name MUST be considered for a match. Subsequent entries
with an equivalent header name MUST be ignored. Due to the
case-insensitivity of header names, "foo" and "Foo" are considered
equivalent.
maxLength: 256
minLength: 1
pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$
type: string
value:
description: Value is the value of HTTP Header
to be matched.
maxLength: 4096
minLength: 1
type: string
required:
- name
- value
type: object
maxItems: 16
type: array
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
type: object
kind:
description: |-
Kind is the kind of the backend resource.
Expand Down
6 changes: 6 additions & 0 deletions site/docs/api/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,11 @@ It can reference either an AIServiceBackend or an InferencePool resource.
type="string"
required="false"
description="Name of the model in the backend. If provided this will override the name provided in the request.<br />This field is ignored when referencing InferencePool resources."
/><ApiField
name="headerMutation"
type="[HTTPHeaderMutation](#httpheadermutation)"
required="false"
description="HeaderMutation defines the request header mutation to be applied to this backend.<br />When both route-level and backend-level HeaderMutation are defined,<br />route-level takes precedence over backend-level for conflicting operations.<br />This field is ignored when referencing InferencePool resources."
/><ApiField
name="weight"
type="integer"
Expand Down Expand Up @@ -1315,6 +1320,7 @@ GCPCredentialsFile specifies the service account key json file to authenticate w


**Appears in:**
- [AIGatewayRouteRuleBackendRef](#aigatewayrouterulebackendref)
- [AIServiceBackendSpec](#aiservicebackendspec)

HTTPHeaderMutation defines the mutation of HTTP headers that will be applied to the request
Expand Down
Loading