Repository: kubernetes-sigs/gateway-api-inference-extension

Issue:
Docs: Quickstart GKE Clean-Up
Update the quickstart guide with instructions for cleaning up the GKE Gateway installation.

Discussion:
@danehans: @robscott @kfswain @ahg-g can someone from the Google docs team help with this issue? See https://github.com/kubernetes-sigs/gateway-api-inference-extension/commit/6a18bebff710ce1596b57e7399814f64ac033084 for details.@robscott: @capri-xiyue are you able to help with this one?@capri-xiyue: @robscott  You can assign it to me. I will do it when I have bandwidth. In addition, if something is GA blocker, please let me know so that I can keep track of it and make sure it get completed before our GA.@capri-xiyue: /assign @danehans: @capri-xiyue thanks for taking on this issue. GKE is the only remaining implementation missing cleanup steps. Do you have an ETA for your PR to fix this issue?@capri-xiyue: @danehans I will send a PR by the end of this week. Does that work for you?@capri-xiyue: https://github.com/kubernetes-sigs/gateway-api-inference-extension/pull/1064 sent

Solution patch:
diff --git a/cmd/epp/runner/runner.go b/cmd/epp/runner/runner.go
index 7bb55b7..0f304de 100644
--- a/cmd/epp/runner/runner.go
+++ b/cmd/epp/runner/runner.go
@@ -46,6 +46,7 @@ import (
 	"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/saturationdetector"
 	"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling"
 	"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework"
+	"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/filter"
 	"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/multi/prefix"
 	"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/picker"
 	"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/profile"
@@ -304,6 +305,7 @@ func (r *Runner) initializeScheduler() (*scheduling.Scheduler, error) {
 		kvCacheScorerWeight := envutil.GetEnvInt("KV_CACHE_SCORE_WEIGHT", scorer.DefaultKVCacheScorerWeight, setupLog)
 
 		schedulerProfile := framework.NewSchedulerProfile().
+			WithFilters(filter.NewSubsetFilter()).
 			WithScorers(framework.NewWeightedScorer(scorer.NewQueueScorer(), queueScorerWeight),
 				framework.NewWeightedScorer(scorer.NewKVCacheScorer(), kvCacheScorerWeight)).
 			WithPicker(picker.NewMaxScorePicker())
diff --git a/pkg/epp/handlers/server.go b/pkg/epp/handlers/server.go
index 85f77cb..e31b53a 100644
--- a/pkg/epp/handlers/server.go
+++ b/pkg/epp/handlers/server.go
@@ -111,8 +111,9 @@ type RequestContext struct {
 }
 
 type Request struct {
-	Headers map[string]string
-	Body    map[string]interface{}
+	Headers  map[string]string
+	Body     map[string]interface{}
+	Metadata map[string]any
 }
 type Response struct {
 	Headers map[string]string
@@ -141,8 +142,9 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer)
 	reqCtx := &RequestContext{
 		RequestState: RequestReceived,
 		Request: &Request{
-			Headers: make(map[string]string),
-			Body:    make(map[string]interface{}),
+			Headers:  make(map[string]string),
+			Body:     make(map[string]interface{}),
+			Metadata: make(map[string]any),
 		},
 		Response: &Response{
 			Headers: make(map[string]string),
@@ -185,6 +187,8 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer)
 			return status.Errorf(codes.Unknown, "cannot receive stream request: %v", err)
 		}
 
+		reqCtx.Request.Metadata = requtil.ExtractMetadataValues(req)
+
 		switch v := req.Request.(type) {
 		case *extProcPb.ProcessingRequest_RequestHeaders:
 			if requestId := requtil.ExtractHeaderValue(v, requtil.RequestIdHeaderKey); len(requestId) > 0 {
diff --git a/pkg/epp/requestcontrol/director.go b/pkg/epp/requestcontrol/director.go
index 5bc5ca4..59cc442 100644
--- a/pkg/epp/requestcontrol/director.go
+++ b/pkg/epp/requestcontrol/director.go
@@ -118,14 +118,15 @@ func (d *Director) HandleRequest(ctx context.Context, reqCtx *handlers.RequestCo
 	}
 
 	// Prepare LLMRequest (needed for both saturation detection and Scheduler)
-	reqCtx.SchedulingRequest = &schedulingtypes.LLMRequest{
-		RequestId:   reqCtx.Request.Headers[requtil.RequestIdHeaderKey],
-		TargetModel: reqCtx.ResolvedTargetModel,
-		Prompt:      prompt,
-		Headers:     reqCtx.Request.Headers,
-	}
+	reqCtx.SchedulingRequest = schedulingtypes.NewLLMRequest(
+		reqCtx.Request.Headers[requtil.RequestIdHeaderKey],
+		reqCtx.ResolvedTargetModel,
+		prompt,
+		reqCtx.Request.Headers,
+		reqCtx.Request.Metadata)
 
 	logger = logger.WithValues("model", reqCtx.Model, "resolvedTargetModel", reqCtx.ResolvedTargetModel, "criticality", requestCriticality)
+
 	ctx = log.IntoContext(ctx, logger)
 	logger.V(logutil.DEBUG).Info("LLM request assembled")
 
diff --git a/pkg/epp/scheduling/framework/plugins/filter/subsetting_filter.go b/pkg/epp/scheduling/framework/plugins/filter/subsetting_filter.go
new file mode 100644
index 0000000..2962b95
--- /dev/null
+++ b/pkg/epp/scheduling/framework/plugins/filter/subsetting_filter.go
@@ -0,0 +1,89 @@
+/*
+Copyright 2025 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package filter
+
+import (
+	"context"
+	"strings"
+
+	"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework"
+	"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types"
+)
+
+const (
+	SubsetFilterType = "subset"
+
+	subsetHintKey       = "x-gateway-destination-endpoint-subset"
+	subsetHintNamespace = "envoy.lb.subset_hint"
+)
+
+// compile-time type assertion
+var _ framework.Filter = &SubsetFilter{}
+
+// NewSubsetFilter initializes a new SubsetFilter.
+func NewSubsetFilter() *SubsetFilter {
+	return &SubsetFilter{}
+}
+
+// SubsetFilter filters Pods based on the subset hint provided by the proxy via filterMetadata.
+type SubsetFilter struct{}
+
+// Name returns the name of the filter.
+func (f *SubsetFilter) Name() string {
+	return "subset-hint"
+}
+
+// Type returns the type of the filter.
+func (f *SubsetFilter) Type() string {
+	return SubsetFilterType
+}
+
+// Filter filters out pods that are not in the subset provided in filterMetadata.
+func (f *SubsetFilter) Filter(_ context.Context, _ *types.CycleState, request *types.LLMRequest, pods []types.Pod) []types.Pod {
+	// Check if subset namespace key is present in the metadata map
+	subsetMap, found := request.GetMetadata()[subsetHintNamespace].(map[string]any)
+	if !found {
+		return pods
+	}
+
+	// Check if endpoint key is present in the subset map and ensure there is at least one value
+	endpointSubsetList, found := subsetMap[subsetHintKey].([]interface{})
+	if !found {
+		return pods
+	} else if len(endpointSubsetList) == 0 {
+		return []types.Pod{}
+	}
+
+	// Create a map of endpoint addrs for easy lookup
+	endpoints := make(map[string]bool)
+	for _, endpoint := range endpointSubsetList {
+		// Extract address from endpoint
+		// The endpoint is formatted as "<address>:<port>" (ex. "10.0.1.0:8080")
+		epStr := strings.Split(endpoint.(string), ":")[0]
+		endpoints[epStr] = true
+	}
+
+	// Filter based on address
+	filteredPods := []types.Pod{}
+	for _, pod := range pods {
+		if _, found := endpoints[pod.GetPod().Address]; found {
+			filteredPods = append(filteredPods, pod)
+		}
+	}
+
+	return filteredPods
+}
diff --git a/pkg/epp/scheduling/scheduler.go b/pkg/epp/scheduling/scheduler.go
index b848b26..f700057 100644
--- a/pkg/epp/scheduling/scheduler.go
+++ b/pkg/epp/scheduling/scheduler.go
@@ -44,6 +44,7 @@ func NewScheduler() *Scheduler {
 	// it's possible to call NewSchedulerWithConfig to pass a different scheduler config.
 	// For build time plugins changes, it's recommended to call in main.go to NewSchedulerWithConfig.
 	loraAffinityFilter := filter.NewLoraAffinityFilter(config.Conf.LoraAffinityThreshold)
+	endpointSubsetFilter := filter.NewSubsetFilter()
 	leastQueueFilter := filter.NewLeastQueueFilter()
 	leastKvCacheFilter := filter.NewLeastKVCacheFilter()
 
@@ -70,7 +71,7 @@ func NewScheduler() *Scheduler {
 	}
 
 	defaultProfile := framework.NewSchedulerProfile().
-		WithFilters(lowLatencyFilter).
+		WithFilters(endpointSubsetFilter, lowLatencyFilter).
 		WithPicker(&picker.RandomPicker{})
 
 	profileHandler := profile.NewSingleProfileHandler()
diff --git a/pkg/epp/scheduling/types/types.go b/pkg/epp/scheduling/types/types.go
index 4513847..d587297 100644
--- a/pkg/epp/scheduling/types/types.go
+++ b/pkg/epp/scheduling/types/types.go
@@ -33,12 +33,29 @@ type LLMRequest struct {
 	Prompt string
 	// Headers is a map of the request headers.
 	Headers map[string]string
+
+	// metadata is a map of metadata in the request
+	metadata map[string]any
+}
+
+func NewLLMRequest(reqID, targetModel, prompt string, headers map[string]string, metadata map[string]any) *LLMRequest {
+	return &LLMRequest{
+		RequestId:   reqID,
+		TargetModel: targetModel,
+		Prompt:      prompt,
+		Headers:     headers,
+		metadata:    metadata,
+	}
 }
 
 func (r *LLMRequest) String() string {
 	return fmt.Sprintf("RequestID: %s, TargetModel: %s, PromptLength: %d, Headers: %v", r.RequestId, r.TargetModel, len(r.Prompt), r.Headers)
 }
 
+func (r *LLMRequest) GetMetadata() map[string]any {
+	return r.metadata
+}
+
 type Pod interface {
 	GetPod() *backend.Pod
 	GetMetrics() *backendmetrics.MetricsState
diff --git a/pkg/epp/util/request/metadata.go b/pkg/epp/util/request/metadata.go
new file mode 100644
index 0000000..2286407
--- /dev/null
+++ b/pkg/epp/util/request/metadata.go
@@ -0,0 +1,31 @@
+/*
+Copyright 2025 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package request
+
+import (
+	extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
+)
+
+func ExtractMetadataValues(req *extProcPb.ProcessingRequest) map[string]any {
+	metadata := make(map[string]any)
+	if req != nil && req.MetadataContext != nil && req.MetadataContext.FilterMetadata != nil {
+		for key, val := range req.MetadataContext.FilterMetadata {
+			metadata[key] = val.AsMap()
+		}
+	}
+	return metadata
+}
diff --git a/site-src/guides/index.md b/site-src/guides/index.md
index 04b781b..ac80149 100644
--- a/site-src/guides/index.md
+++ b/site-src/guides/index.md
@@ -321,7 +321,7 @@ This quickstart guide is intended for engineers familiar with k8s and model serv
 
 === "GKE"
 
-      **TODO**
+      No further clean up is needed.
 
 === "Istio"
 



Test patch:
diff --git a/pkg/epp/scheduling/framework/plugins/filter/filter_test.go b/pkg/epp/scheduling/framework/plugins/filter/filter_test.go
index 464936e..1e89f7e 100644
--- a/pkg/epp/scheduling/framework/plugins/filter/filter_test.go
+++ b/pkg/epp/scheduling/framework/plugins/filter/filter_test.go
@@ -251,3 +251,142 @@ func TestLoRASoftAffinityDistribution(t *testing.T) {
 			actualAvailablePercent, availableLowerBound, availableUpperBound)
 	}
 }
+
+func TestSubsettingFilter(t *testing.T) {
+	var makeFilterMetadata = func(data []interface{}) map[string]any {
+		return map[string]any{
+			"envoy.lb.subset_hint": map[string]any{
+				"x-gateway-destination-endpoint-subset": data,
+			},
+		}
+	}
+
+	tests := []struct {
+		name     string
+		metadata map[string]any
+		filter   framework.Filter
+		input    []types.Pod
+		output   []types.Pod
+	}{
+		{
+			name:     "SubsetFilter, filter not present — return all pods",
+			filter:   &SubsetFilter{},
+			metadata: map[string]any{},
+			input: []types.Pod{
+				&types.PodMetrics{
+					Pod: &backend.Pod{Address: "10.0.0.1"},
+				},
+				&types.PodMetrics{
+					Pod: &backend.Pod{Address: "10.0.0.2"},
+				},
+			},
+			output: []types.Pod{
+				&types.PodMetrics{
+					Pod: &backend.Pod{Address: "10.0.0.1"},
+				},
+				&types.PodMetrics{
+					Pod: &backend.Pod{Address: "10.0.0.2"},
+				},
+			},
+		},
+		{
+			name:     "SubsetFilter, namespace present filter not present — return all pods",
+			filter:   &SubsetFilter{},
+			metadata: map[string]any{"envoy.lb.subset_hint": map[string]any{}},
+			input: []types.Pod{
+				&types.PodMetrics{
+					Pod: &backend.Pod{Address: "10.0.0.1"},
+				},
+				&types.PodMetrics{
+					Pod: &backend.Pod{Address: "10.0.0.2"},
+				},
+			},
+			output: []types.Pod{
+				&types.PodMetrics{
+					Pod: &backend.Pod{Address: "10.0.0.1"},
+				},
+				&types.PodMetrics{
+					Pod: &backend.Pod{Address: "10.0.0.2"},
+				},
+			},
+		},
+		{
+			name:     "SubsetFilter, filter present with empty list — return no pods",
+			filter:   &SubsetFilter{},
+			metadata: makeFilterMetadata([]interface{}{}),
+			input: []types.Pod{
+				&types.PodMetrics{
+					Pod: &backend.Pod{Address: "10.0.0.1"},
+				},
+				&types.PodMetrics{
+					Pod: &backend.Pod{Address: "10.0.0.2"},
+				},
+			},
+			output: []types.Pod{},
+		},
+		{
+			name:     "SubsetFilter, subset with one matching pod",
+			metadata: makeFilterMetadata([]interface{}{"10.0.0.1"}),
+			filter:   &SubsetFilter{},
+			input: []types.Pod{
+				&types.PodMetrics{
+					Pod: &backend.Pod{Address: "10.0.0.1"},
+				},
+				&types.PodMetrics{
+					Pod: &backend.Pod{Address: "10.0.0.2"},
+				},
+			},
+			output: []types.Pod{
+				&types.PodMetrics{
+					Pod: &backend.Pod{Address: "10.0.0.1"},
+				},
+			},
+		},
+		{
+			name:     "SubsetFilter, subset with multiple matching pods",
+			metadata: makeFilterMetadata([]interface{}{"10.0.0.1", "10.0.0.2", "10.0.0.3"}),
+			filter:   &SubsetFilter{},
+			input: []types.Pod{
+				&types.PodMetrics{
+					Pod: &backend.Pod{Address: "10.0.0.1"},
+				},
+				&types.PodMetrics{
+					Pod: &backend.Pod{Address: "10.0.0.2"},
+				},
+			},
+			output: []types.Pod{
+				&types.PodMetrics{
+					Pod: &backend.Pod{Address: "10.0.0.1"},
+				},
+				&types.PodMetrics{
+					Pod: &backend.Pod{Address: "10.0.0.2"},
+				},
+			},
+		},
+		{
+			name:     "SubsetFilter, subset with no matching pods",
+			metadata: makeFilterMetadata([]interface{}{"10.0.0.3"}),
+			filter:   &SubsetFilter{},
+			input: []types.Pod{
+				&types.PodMetrics{
+					Pod: &backend.Pod{Address: "10.0.0.1"},
+				},
+				&types.PodMetrics{
+					Pod: &backend.Pod{Address: "10.0.0.2"},
+				},
+			},
+			output: []types.Pod{},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			req := types.NewLLMRequest(uuid.NewString(), "", "", nil, test.metadata)
+			got := test.filter.Filter(context.Background(), types.NewCycleState(), req, test.input)
+
+			if diff := cmp.Diff(test.output, got); diff != "" {
+				t.Errorf("Unexpected output (-want +got): %v", diff)
+			}
+		})
+	}
+}
diff --git a/pkg/epp/util/request/metadata_test.go b/pkg/epp/util/request/metadata_test.go
new file mode 100644
index 0000000..0a0c71e
--- /dev/null
+++ b/pkg/epp/util/request/metadata_test.go
@@ -0,0 +1,71 @@
+/*
+Copyright 2025 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package request
+
+import (
+	"testing"
+
+	corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
+	extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
+	"github.com/google/go-cmp/cmp"
+	"google.golang.org/protobuf/types/known/structpb"
+)
+
+func TestExtractMetadataValues(t *testing.T) {
+	var makeFilterMetadata = func() map[string]*structpb.Struct {
+		structVal, _ := structpb.NewStruct(map[string]interface{}{
+			"hello":      "world",
+			"random-key": []interface{}{"hello", "world"},
+		})
+
+		return map[string]*structpb.Struct{
+			"key-1": structVal,
+		}
+	}
+
+	tests := []struct {
+		name     string
+		metadata map[string]*structpb.Struct
+		expected map[string]any
+	}{
+		{
+			name:     "Exact match",
+			metadata: makeFilterMetadata(),
+			expected: map[string]any{
+				"key-1": map[string]interface{}{
+					"hello":      "world",
+					"random-key": []interface{}{"hello", "world"},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			req := &extProcPb.ProcessingRequest{
+				MetadataContext: &corev3.Metadata{
+					FilterMetadata: tt.metadata,
+				},
+			}
+
+			result := ExtractMetadataValues(req)
+			if diff := cmp.Diff(result, tt.expected); diff != "" {
+				t.Errorf("ExtractMetadataValues() unexpected response (-want +got): %v", diff)
+			}
+		})
+	}
+}
diff --git a/test/integration/bbr/hermetic_test.go b/test/integration/bbr/hermetic_test.go
index b99186d..8fd479f 100644
--- a/test/integration/bbr/hermetic_test.go
+++ b/test/integration/bbr/hermetic_test.go
@@ -45,7 +45,7 @@ func TestBodyBasedRouting(t *testing.T) {
 	}{
 		{
 			name: "success adding model parameter to header",
-			req:  integrationutils.GenerateRequest(logger, "test", "llama"),
+			req:  integrationutils.GenerateRequest(logger, "test", "llama", nil),
 			wantHeaders: []*configPb.HeaderValueOption{
 				{
 					Header: &configPb.HeaderValue{
@@ -58,7 +58,7 @@ func TestBodyBasedRouting(t *testing.T) {
 		},
 		{
 			name:        "no model parameter",
-			req:         integrationutils.GenerateRequest(logger, "test1", ""),
+			req:         integrationutils.GenerateRequest(logger, "test1", "", nil),
 			wantHeaders: []*configPb.HeaderValueOption{},
 			wantErr:     false,
 		},
@@ -107,7 +107,7 @@ func TestFullDuplexStreamed_BodyBasedRouting(t *testing.T) {
 	}{
 		{
 			name: "success adding model parameter to header",
-			reqs: integrationutils.GenerateStreamedRequestSet(logger, "test", "foo"),
+			reqs: integrationutils.GenerateStreamedRequestSet(logger, "test", "foo", nil),
 			wantResponses: []*extProcPb.ProcessingResponse{
 				{
 					Response: &extProcPb.ProcessingResponse_RequestHeaders{
@@ -212,7 +212,7 @@ func TestFullDuplexStreamed_BodyBasedRouting(t *testing.T) {
 		},
 		{
 			name: "no model parameter",
-			reqs: integrationutils.GenerateStreamedRequestSet(logger, "test", ""),
+			reqs: integrationutils.GenerateStreamedRequestSet(logger, "test", "", nil),
 			wantResponses: []*extProcPb.ProcessingResponse{
 				{
 					Response: &extProcPb.ProcessingResponse_RequestHeaders{
diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go
index db62529..2b9bb76 100644
--- a/test/integration/epp/hermetic_test.go
+++ b/test/integration/epp/hermetic_test.go
@@ -152,7 +152,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) {
 		// Request flow tests
 		{
 			name:     "select lower queue and kv cache, no active lora",
-			requests: integrationutils.GenerateStreamedRequestSet(logger, "test1", modelMyModel),
+			requests: integrationutils.GenerateStreamedRequestSet(logger, "test1", modelMyModel, nil),
 			// Pod 1 will be picked because it has relatively low queue size and low KV cache.
 			pods: newPodStates(
 				podState{index: 0, queueSize: 3, kvCacheUsage: 0.2},
@@ -217,7 +217,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) {
 		},
 		{
 			name:     "select active lora, low queue",
-			requests: integrationutils.GenerateStreamedRequestSet(logger, "test2", modelSQLLora),
+			requests: integrationutils.GenerateStreamedRequestSet(logger, "test2", modelSQLLora, nil),
 			// Pod 1 will be picked because it has relatively low queue size, the requested model active, and low KV cache.
 			pods: newPodStates(
 				podState{index: 0, queueSize: 0, kvCacheUsage: 0.2, activeModels: []string{"foo", "bar"}},
@@ -245,7 +245,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) {
 		},
 		{
 			name:     "select no lora despite active model, avoid excessive queue size",
-			requests: integrationutils.GenerateStreamedRequestSet(logger, "test3", modelSQLLora),
+			requests: integrationutils.GenerateStreamedRequestSet(logger, "test3", modelSQLLora, nil),
 			// Pod 2 will be picked despite NOT having the requested model active as it is above the affinity for queue size.
 			// Also it is critical, so we should still admit the request despite all queue sizes being greater than the queue
 			// size threshold.
@@ -274,7 +274,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) {
 		},
 		{
 			name:     "noncritical and all models past threshold, shed request",
-			requests: integrationutils.GenerateStreamedRequestSet(logger, "test4", modelSheddable),
+			requests: integrationutils.GenerateStreamedRequestSet(logger, "test4", modelSheddable, nil),
 			// pod 0: excluded; above queue size threshold
 			// pod 1: excluded; above KV cache threshold
 			// pod 2: excluded; above queue size threshold
@@ -292,7 +292,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) {
 		},
 		{
 			name:     "noncritical, but one server has capacity, do not shed",
-			requests: integrationutils.GenerateStreamedRequestSet(logger, "test5", modelSheddable),
+			requests: integrationutils.GenerateStreamedRequestSet(logger, "test5", modelSheddable, nil),
 			// Pod 1 will be picked because it has relatively low queue size and low KV cache.
 			pods: newPodStates(
 				podState{index: 0, queueSize: 4, kvCacheUsage: 0.2, activeModels: []string{"foo", "bar", modelSheddableTarget}},
@@ -736,6 +736,99 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) {
 			),
 			wantMetrics: map[string]string{},
 		},
+		{
+			name: "select active lora with subsetting tag, all pods available",
+			requests: integrationutils.GenerateStreamedRequestSet(
+				logger,
+				"test2",
+				modelSQLLora,
+				[]string{"192.168.1.1:8000", "192.168.1.2:8000", "192.168.1.3:8000"}),
+			// Pod 1 will be picked because it has relatively low queue size, the requested model active, low KV cache, and within subset.
+			pods: newPodStates(
+				podState{index: 0, queueSize: 0, kvCacheUsage: 0.2, activeModels: []string{"foo", "bar"}},
+				podState{index: 1, queueSize: 0, kvCacheUsage: 0.1, activeModels: []string{"foo", modelSQLLoraTarget}},
+				podState{index: 2, queueSize: 10, kvCacheUsage: 0.2, activeModels: []string{"foo", "bar"}},
+			),
+
+			wantMetrics: map[string]string{
+				"inference_model_request_total": inferenceModelRequestTotal([]label{
+					{"model_name", modelSQLLora},
+					{"target_model_name", modelSQLLoraTarget},
+				}),
+			},
+			wantErr: false,
+			wantResponses: integrationutils.NewRequestBufferedResponse(
+				"192.168.1.2:8000",
+				fmt.Sprintf(`{"max_tokens":100,"model":%q,"prompt":"test2","temperature":0}`, modelSQLLoraTarget),
+				&configPb.HeaderValueOption{
+					Header: &configPb.HeaderValue{
+						Key:      "hi",
+						RawValue: []byte("mom"),
+					},
+				},
+			),
+		},
+		{
+			name: "select active lora with subsetting tag, some pods match",
+			requests: integrationutils.GenerateStreamedRequestSet(
+				logger,
+				"test2",
+				modelSQLLora,
+				[]string{"192.168.1.3:8000"}),
+			// Pod 3 has high queue and kv cache utilization, but it will still be picked because it is the only one matching subsetting target.
+			pods: newPodStates(
+				podState{index: 0, queueSize: 0, kvCacheUsage: 0.2, activeModels: []string{"foo", "bar"}},
+				podState{index: 1, queueSize: 0, kvCacheUsage: 0.1, activeModels: []string{"foo", modelSQLLoraTarget}},
+				podState{index: 2, queueSize: 10, kvCacheUsage: 0.2, activeModels: []string{"foo", "bar"}},
+			),
+
+			wantMetrics: map[string]string{
+				"inference_model_request_total": inferenceModelRequestTotal([]label{
+					{"model_name", modelSQLLora},
+					{"target_model_name", modelSQLLoraTarget},
+				}),
+			},
+			wantErr: false,
+			wantResponses: integrationutils.NewRequestBufferedResponse(
+				"192.168.1.3:8000",
+				fmt.Sprintf(`{"max_tokens":100,"model":%q,"prompt":"test2","temperature":0}`, modelSQLLoraTarget),
+				&configPb.HeaderValueOption{
+					Header: &configPb.HeaderValue{
+						Key:      "hi",
+						RawValue: []byte("mom"),
+					},
+				},
+			),
+		},
+		{
+			name: "select active lora with subsetting tag, no pods available",
+			requests: integrationutils.GenerateStreamedRequestSet(
+				logger,
+				"test2",
+				modelSQLLora,
+				[]string{"192.168.1.4:8000", "192.168.1.5:8000", "192.168.1.6:8000"}),
+			// No pods will be picked as none are within the subset.
+			pods: newPodStates(
+				podState{index: 0, queueSize: 0, kvCacheUsage: 0.2, activeModels: []string{"foo", "bar"}},
+				podState{index: 1, queueSize: 0, kvCacheUsage: 0.1, activeModels: []string{"foo", modelSQLLoraTarget}},
+				podState{index: 2, queueSize: 10, kvCacheUsage: 0.2, activeModels: []string{"foo", "bar"}},
+			),
+
+			wantMetrics: map[string]string{},
+			wantErr:     true,
+			wantResponses: []*extProcPb.ProcessingResponse{
+				{
+					Response: &extProcPb.ProcessingResponse_ImmediateResponse{
+						ImmediateResponse: &extProcPb.ImmediateResponse{
+							Status: &envoyTypePb.HttpStatus{
+								Code: envoyTypePb.StatusCode_TooManyRequests,
+							},
+							Body: []byte("inference gateway: InferencePoolResourceExhausted - failed to find target pod: failed to run scheduler profile 'default'"),
+						},
+					},
+				},
+			},
+		},
 	}
 
 	for _, test := range tests {
diff --git a/test/integration/util.go b/test/integration/util.go
index 4401d9d..a1baa33 100644
--- a/test/integration/util.go
+++ b/test/integration/util.go
@@ -86,7 +86,7 @@ func StreamedRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessCli
 	return responses, nil
 }
 
-func GenerateRequest(logger logr.Logger, prompt, model string) *extProcPb.ProcessingRequest {
+func GenerateRequest(logger logr.Logger, prompt, model string, filterMetadata []string) *extProcPb.ProcessingRequest {
 	j := map[string]interface{}{
 		"prompt":      prompt,
 		"max_tokens":  100,
@@ -104,11 +104,14 @@ func GenerateRequest(logger logr.Logger, prompt, model string) *extProcPb.Proces
 		Request: &extProcPb.ProcessingRequest_RequestBody{
 			RequestBody: &extProcPb.HttpBody{Body: llmReq, EndOfStream: true},
 		},
+		MetadataContext: &envoyCorev3.Metadata{
+			FilterMetadata: GenerateRequestMetadata(filterMetadata),
+		},
 	}
 	return req
 }
 
-func GenerateStreamedRequestSet(logger logr.Logger, prompt, model string) []*extProcPb.ProcessingRequest {
+func GenerateStreamedRequestSet(logger logr.Logger, prompt, model string, filterMetadata []string) []*extProcPb.ProcessingRequest {
 	requests := []*extProcPb.ProcessingRequest{}
 	headerReq := &extProcPb.ProcessingRequest{
 		Request: &extProcPb.ProcessingRequest_RequestHeaders{
@@ -124,11 +127,31 @@ func GenerateStreamedRequestSet(logger logr.Logger, prompt, model string) []*ext
 			},
 		},
 	}
+
+	headerReq.MetadataContext = &envoyCorev3.Metadata{
+		FilterMetadata: GenerateRequestMetadata(filterMetadata),
+	}
+
 	requests = append(requests, headerReq)
-	requests = append(requests, GenerateRequest(logger, prompt, model))
+	requests = append(requests, GenerateRequest(logger, prompt, model, filterMetadata))
 	return requests
 }
 
+func GenerateRequestMetadata(filterMetadata []string) map[string]*structpb.Struct {
+	metadata := make(map[string]*structpb.Struct)
+	interfaceList := make([]interface{}, len(filterMetadata))
+	for i, val := range filterMetadata {
+		interfaceList[i] = val
+	}
+	if filterMetadata != nil {
+		structVal, _ := structpb.NewStruct(map[string]interface{}{
+			"x-gateway-destination-endpoint-subset": interfaceList,
+		})
+		metadata["envoy.lb.subset_hint"] = structVal
+	}
+	return metadata
+}
+
 // NewRequestBufferedResponse creates a complete set of responses for the request phase.
 // It modifies request headers (e.g., for routing) and replaces the entire request body.
 // It returns a slice of two messages, representing the complete buffered action.