@@ -19,16 +19,22 @@ package bbr
1919
2020import (
2121 "context"
22+ "encoding/json"
2223 "strings"
2324 "testing"
2425
26+ envoyCorev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
2527 extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
2628 envoyTypePb "github.com/envoyproxy/go-control-plane/envoy/type/v3"
2729 "github.com/google/go-cmp/cmp"
2830 "github.com/stretchr/testify/require"
2931 "google.golang.org/protobuf/testing/protocmp"
3032
33+ "sigs.k8s.io/gateway-api-inference-extension/pkg/bbr/framework"
34+ "sigs.k8s.io/gateway-api-inference-extension/pkg/bbr/plugins/basemodelextractor"
35+ "sigs.k8s.io/gateway-api-inference-extension/pkg/bbr/plugins/bodyfieldtoheader"
3136 envoytest "sigs.k8s.io/gateway-api-inference-extension/pkg/common/envoy/test"
37+ epp "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/framework/interface/plugin"
3238 "sigs.k8s.io/gateway-api-inference-extension/test/integration"
3339)
3440
@@ -176,3 +182,175 @@ func TestFullDuplexStreamed_BodyBasedRouting(t *testing.T) {
176182 })
177183 }
178184}
185+
186+ // testResponsePlugin implements framework.ResponseProcessor for integration tests.
187+ type testResponsePlugin struct {
188+ name string
189+ mutateFn func (ctx context.Context , response * framework.InferenceResponse ) error
190+ }
191+
192+ func (p * testResponsePlugin ) TypedName () epp.TypedName {
193+ return epp.TypedName {Type : "test" , Name : p .name }
194+ }
195+
196+ func (p * testResponsePlugin ) ProcessResponse (ctx context.Context , _ * framework.CycleState , response * framework.InferenceResponse ) error {
197+ return p .mutateFn (ctx , response )
198+ }
199+
200+ var _ framework.ResponseProcessor = & testResponsePlugin {}
201+
202+ // TestResponsePlugins_Unary validates that response plugins can mutate the
203+ // response body in unary (non-streaming) mode over a real gRPC ext_proc stream.
204+ func TestResponsePlugins_Unary (t * testing.T ) {
205+ t .Parallel ()
206+
207+ guardrailPlugin := & testResponsePlugin {
208+ name : "guardrail" ,
209+ mutateFn : func (_ context.Context , response * framework.InferenceResponse ) error {
210+ response .SetBodyField ("guardrail" , "applied" )
211+ return nil
212+ },
213+ }
214+
215+ ctx := context .Background ()
216+
217+ modelToHeaderPlugin , err := bodyfieldtoheader .NewBodyFieldToHeaderPlugin (modelField , bodyfieldtoheader .ModelHeader )
218+ require .NoError (t , err , "failed to create body-field-to-header plugin" )
219+ baseModelPlugin := & basemodelextractor.BaseModelToHeaderPlugin {AdaptersStore : basemodelextractor .NewAdaptersStore ()}
220+
221+ h := NewBBRHarnessWithPlugins (t , ctx , false , []framework.RequestProcessor {modelToHeaderPlugin , baseModelPlugin }, []framework.ResponseProcessor {guardrailPlugin })
222+
223+ // Phase 1: Send request body (unary — no separate headers message).
224+ requestBody := map [string ]any {
225+ "prompt" : "hello" ,
226+ "max_tokens" : 100 ,
227+ "temperature" : 0 ,
228+ "model" : "test-model" ,
229+ }
230+ reqBodyBytes , err := json .Marshal (requestBody )
231+ require .NoError (t , err )
232+
233+ // Phase 2: Build the full message sequence: RequestBody → ResponseHeaders → ResponseBody.
234+ responseBody := map [string ]any {
235+ "choices" : []any {
236+ map [string ]any {"text" : "Hello!" },
237+ },
238+ }
239+ respBodyBytes , err := json .Marshal (responseBody )
240+ require .NoError (t , err )
241+
242+ reqs := []* extProcPb.ProcessingRequest {
243+ {
244+ Request : & extProcPb.ProcessingRequest_RequestBody {
245+ RequestBody : & extProcPb.HttpBody {Body : reqBodyBytes , EndOfStream : true },
246+ },
247+ },
248+ {
249+ Request : & extProcPb.ProcessingRequest_ResponseHeaders {
250+ ResponseHeaders : & extProcPb.HttpHeaders {
251+ Headers : & envoyCorev3.HeaderMap {
252+ Headers : []* envoyCorev3.HeaderValue {
253+ {Key : "content-type" , Value : "application/json" },
254+ },
255+ },
256+ },
257+ },
258+ },
259+ {
260+ Request : & extProcPb.ProcessingRequest_ResponseBody {
261+ ResponseBody : & extProcPb.HttpBody {Body : respBodyBytes , EndOfStream : true },
262+ },
263+ },
264+ }
265+
266+ // Expect 3 responses: request body, response headers, response body.
267+ responses , err := integration .StreamedRequest (t , h .Client , reqs , 3 )
268+ require .NoError (t , err , "unexpected error during streamed request" )
269+ require .Len (t , responses , 3 )
270+
271+ // The response body should contain the guardrail field injected by the plugin.
272+ expectedRespBody := map [string ]any {
273+ "choices" : []any {
274+ map [string ]any {"text" : "Hello!" },
275+ },
276+ "guardrail" : "applied" ,
277+ }
278+
279+ wantResponses := []* extProcPb.ProcessingResponse {
280+ ExpectBBRUnaryResponse ("test-model" , "" , "hello" ),
281+ ExpectResponseHeadersPassThrough (),
282+ ExpectResponseBodyMutation (expectedRespBody ),
283+ }
284+
285+ envoytest .SortSetHeadersInResponses (wantResponses )
286+ envoytest .SortSetHeadersInResponses (responses )
287+ if diff := cmp .Diff (wantResponses , responses , protocmp .Transform ()); diff != "" {
288+ t .Errorf ("Response mismatch (-want +got): %v" , diff )
289+ }
290+ }
291+
292+ // TestResponsePlugins_NoPlugins_Unary validates that when no response plugins
293+ // are configured, the response body is passed through without mutation.
294+ func TestResponsePlugins_NoPlugins_Unary (t * testing.T ) {
295+ t .Parallel ()
296+
297+ ctx := context .Background ()
298+ h := NewBBRHarness (t , ctx , false )
299+
300+ requestBody := map [string ]any {
301+ "prompt" : "hello" ,
302+ "max_tokens" : 100 ,
303+ "temperature" : 0 ,
304+ "model" : "test-model" ,
305+ }
306+ reqBodyBytes , err := json .Marshal (requestBody )
307+ require .NoError (t , err )
308+
309+ responseBody := map [string ]any {
310+ "choices" : []any {
311+ map [string ]any {"text" : "Hi there!" },
312+ },
313+ }
314+ respBodyBytes , err := json .Marshal (responseBody )
315+ require .NoError (t , err )
316+
317+ reqs := []* extProcPb.ProcessingRequest {
318+ {
319+ Request : & extProcPb.ProcessingRequest_RequestBody {
320+ RequestBody : & extProcPb.HttpBody {Body : reqBodyBytes , EndOfStream : true },
321+ },
322+ },
323+ {
324+ Request : & extProcPb.ProcessingRequest_ResponseHeaders {
325+ ResponseHeaders : & extProcPb.HttpHeaders {
326+ Headers : & envoyCorev3.HeaderMap {
327+ Headers : []* envoyCorev3.HeaderValue {
328+ {Key : "content-type" , Value : "application/json" },
329+ },
330+ },
331+ },
332+ },
333+ },
334+ {
335+ Request : & extProcPb.ProcessingRequest_ResponseBody {
336+ ResponseBody : & extProcPb.HttpBody {Body : respBodyBytes , EndOfStream : true },
337+ },
338+ },
339+ }
340+
341+ responses , err := integration .StreamedRequest (t , h .Client , reqs , 3 )
342+ require .NoError (t , err , "unexpected error during streamed request" )
343+ require .Len (t , responses , 3 )
344+
345+ wantResponses := []* extProcPb.ProcessingResponse {
346+ ExpectBBRUnaryResponse ("test-model" , "" , "hello" ),
347+ ExpectResponseHeadersPassThrough (),
348+ ExpectResponseBodyPassThrough (),
349+ }
350+
351+ envoytest .SortSetHeadersInResponses (wantResponses )
352+ envoytest .SortSetHeadersInResponses (responses )
353+ if diff := cmp .Diff (wantResponses , responses , protocmp .Transform ()); diff != "" {
354+ t .Errorf ("Response mismatch (-want +got): %v" , diff )
355+ }
356+ }
0 commit comments