@@ -41,6 +41,8 @@ import (
4141
4242const modelField = "model"
4343
44+ // === Request Headers Tests ===
45+
4446func TestHandleRequestHeaders (t * testing.T ) {
4547 tests := []struct {
4648 name string
@@ -196,6 +198,8 @@ func TestHandleRequestHeaders(t *testing.T) {
196198 }
197199}
198200
201+ // === Request Body Tests (built-in plugins) ===
202+
199203func TestHandleRequestBody (t * testing.T ) {
200204 metrics .Register ()
201205 ctx := logutil .NewTestLoggerIntoContext (context .Background ())
@@ -529,7 +533,7 @@ func TestHandleRequestBody(t *testing.T) {
529533 })
530534 }
531535
532- // Assert BBR metrics: 2 model not in body, 1 model empty string, 4 successful model-from-body cases.
536+ // Assert BBR metrics: 2 model not in body, 1 model empty string, 7 successful model-from-body cases.
533537 wantMetrics := `
534538 # HELP bbr_body_field_empty_total [ALPHA] Count of times a field was found in a request body but was empty.
535539 # TYPE bbr_body_field_empty_total counter
@@ -594,6 +598,299 @@ func TestHandleRequestBodyWithPluginMetrics(t *testing.T) {
594598 }
595599}
596600
601+ // === Request Body Tests (multi-plugin header mutations) ===
602+
603+ // fakeRequestPlugin implements framework.RequestProcessor for testing
604+ // multi-plugin header mutation scenarios.
605+ type fakeRequestPlugin struct {
606+ name string
607+ mutateFn func (ctx context.Context , request * framework.InferenceRequest ) error
608+ }
609+
610+ func (p * fakeRequestPlugin ) TypedName () epp.TypedName {
611+ return epp.TypedName {Type : "fake" , Name : p .name }
612+ }
613+
614+ func (p * fakeRequestPlugin ) ProcessRequest (ctx context.Context , _ * framework.CycleState , request * framework.InferenceRequest ) error {
615+ return p .mutateFn (ctx , request )
616+ }
617+
618+ var _ framework.RequestProcessor = & fakeRequestPlugin {}
619+
620+ // TestHandleRequestBody_MultiPluginHeaderMutations tests the end-to-end behavior of
621+ // HandleRequestBody when multiple request plugins set and/or remove headers.
622+ // Each sub-test verifies the HeaderMutation in the resulting ProcessingResponse.
623+ func TestHandleRequestBody_MultiPluginHeaderMutations (t * testing.T ) {
624+ ctx := logutil .NewTestLoggerIntoContext (context .Background ())
625+
626+ tests := []struct {
627+ name string
628+ plugins []framework.RequestProcessor
629+ initialHeaders map [string ]string
630+ wantSetHeaders map [string ]string
631+ wantRemoved []string
632+ }{
633+ {
634+ // Plugin1 adds X-Custom, Plugin2 removes it.
635+ // The header was never in the original Envoy request, so Envoy treats
636+ // the removal as a no-op. The net visible effect is: nothing changed.
637+ // However, RemoveHeader() does record it in removedHeaders because
638+ // the key existed in Headers at the time of removal.
639+ name : "set then remove same header - cancels out" ,
640+ plugins : []framework.RequestProcessor {
641+ & fakeRequestPlugin {
642+ name : "setter" ,
643+ mutateFn : func (_ context.Context , req * framework.InferenceRequest ) error {
644+ req .SetHeader ("X-Custom" , "value1" )
645+ return nil
646+ },
647+ },
648+ & fakeRequestPlugin {
649+ name : "remover" ,
650+ mutateFn : func (_ context.Context , req * framework.InferenceRequest ) error {
651+ req .RemoveHeader ("X-Custom" )
652+ return nil
653+ },
654+ },
655+ },
656+ wantSetHeaders : map [string ]string {},
657+ wantRemoved : []string {"X-Custom" },
658+ },
659+ {
660+ // Plugin1 adds a new header, Plugin2 removes a pre-existing one.
661+ // Both mutations should appear in the response.
662+ name : "set then remove different headers - both apply" ,
663+ plugins : []framework.RequestProcessor {
664+ & fakeRequestPlugin {
665+ name : "setter" ,
666+ mutateFn : func (_ context.Context , req * framework.InferenceRequest ) error {
667+ req .SetHeader ("X-New" , "hello" )
668+ return nil
669+ },
670+ },
671+ & fakeRequestPlugin {
672+ name : "remover" ,
673+ mutateFn : func (_ context.Context , req * framework.InferenceRequest ) error {
674+ req .RemoveHeader ("X-Existing" )
675+ return nil
676+ },
677+ },
678+ },
679+ initialHeaders : map [string ]string {
680+ "X-Existing" : "old-value" ,
681+ },
682+ wantSetHeaders : map [string ]string {
683+ "X-New" : "hello" ,
684+ },
685+ wantRemoved : []string {"X-Existing" },
686+ },
687+ {
688+ // RemoveHeader on a key that was never in Headers is a no-op:
689+ // the guard `if _, ok := r.Headers[key]; ok` prevents any mutation.
690+ name : "remove non-existing header - no-op" ,
691+ plugins : []framework.RequestProcessor {
692+ & fakeRequestPlugin {
693+ name : "remover" ,
694+ mutateFn : func (_ context.Context , req * framework.InferenceRequest ) error {
695+ req .RemoveHeader ("X-Ghost" )
696+ return nil
697+ },
698+ },
699+ },
700+ wantSetHeaders : map [string ]string {},
701+ wantRemoved : nil ,
702+ },
703+ {
704+ // Plugin1 removes a pre-existing header, Plugin2 re-sets it.
705+ // SetHeader clears the key from removedHeaders, so the final result
706+ // is a set with the new value and no removal.
707+ name : "remove then set same header - new value wins" ,
708+ plugins : []framework.RequestProcessor {
709+ & fakeRequestPlugin {
710+ name : "remover" ,
711+ mutateFn : func (_ context.Context , req * framework.InferenceRequest ) error {
712+ req .RemoveHeader ("X-Reuse" )
713+ return nil
714+ },
715+ },
716+ & fakeRequestPlugin {
717+ name : "setter" ,
718+ mutateFn : func (_ context.Context , req * framework.InferenceRequest ) error {
719+ req .SetHeader ("X-Reuse" , "new-value" )
720+ return nil
721+ },
722+ },
723+ },
724+ initialHeaders : map [string ]string {
725+ "X-Reuse" : "old-value" ,
726+ },
727+ wantSetHeaders : map [string ]string {
728+ "X-Reuse" : "new-value" ,
729+ },
730+ wantRemoved : nil ,
731+ },
732+ {
733+ // Both plugins set the same header key. Plugins run sequentially,
734+ // so the last writer wins.
735+ name : "two plugins set same header - last wins" ,
736+ plugins : []framework.RequestProcessor {
737+ & fakeRequestPlugin {
738+ name : "setter1" ,
739+ mutateFn : func (_ context.Context , req * framework.InferenceRequest ) error {
740+ req .SetHeader ("X-Shared" , "first" )
741+ return nil
742+ },
743+ },
744+ & fakeRequestPlugin {
745+ name : "setter2" ,
746+ mutateFn : func (_ context.Context , req * framework.InferenceRequest ) error {
747+ req .SetHeader ("X-Shared" , "second" )
748+ return nil
749+ },
750+ },
751+ },
752+ wantSetHeaders : map [string ]string {
753+ "X-Shared" : "second" ,
754+ },
755+ wantRemoved : nil ,
756+ },
757+ {
758+ // Two plugins set different header keys. Both should appear in the response.
759+ name : "two plugins set different headers - both apply" ,
760+ plugins : []framework.RequestProcessor {
761+ & fakeRequestPlugin {
762+ name : "setter-a" ,
763+ mutateFn : func (_ context.Context , req * framework.InferenceRequest ) error {
764+ req .SetHeader ("X-First" , "aaa" )
765+ return nil
766+ },
767+ },
768+ & fakeRequestPlugin {
769+ name : "setter-b" ,
770+ mutateFn : func (_ context.Context , req * framework.InferenceRequest ) error {
771+ req .SetHeader ("X-Second" , "bbb" )
772+ return nil
773+ },
774+ },
775+ },
776+ wantSetHeaders : map [string ]string {
777+ "X-First" : "aaa" ,
778+ "X-Second" : "bbb" ,
779+ },
780+ wantRemoved : nil ,
781+ },
782+ {
783+ // Two plugins both remove the same pre-existing header.
784+ // The second RemoveHeader is a no-op because the header is already gone.
785+ // The header should appear exactly once in removedHeaders.
786+ name : "two plugins remove same header - idempotent" ,
787+ plugins : []framework.RequestProcessor {
788+ & fakeRequestPlugin {
789+ name : "remover1" ,
790+ mutateFn : func (_ context.Context , req * framework.InferenceRequest ) error {
791+ req .RemoveHeader ("X-Dup" )
792+ return nil
793+ },
794+ },
795+ & fakeRequestPlugin {
796+ name : "remover2" ,
797+ mutateFn : func (_ context.Context , req * framework.InferenceRequest ) error {
798+ req .RemoveHeader ("X-Dup" )
799+ return nil
800+ },
801+ },
802+ },
803+ initialHeaders : map [string ]string {
804+ "X-Dup" : "value" ,
805+ },
806+ wantSetHeaders : map [string ]string {},
807+ wantRemoved : []string {"X-Dup" },
808+ },
809+ {
810+ // A plugin sets a header to the same value it already has.
811+ // The SetHeader optimization (types.go:43) should skip the mutation.
812+ name : "set existing header to same value - no mutation" ,
813+ plugins : []framework.RequestProcessor {
814+ & fakeRequestPlugin {
815+ name : "noop-setter" ,
816+ mutateFn : func (_ context.Context , req * framework.InferenceRequest ) error {
817+ req .SetHeader ("X-Keep" , "original" )
818+ return nil
819+ },
820+ },
821+ },
822+ initialHeaders : map [string ]string {
823+ "X-Keep" : "original" ,
824+ },
825+ wantSetHeaders : map [string ]string {},
826+ wantRemoved : nil ,
827+ },
828+ }
829+
830+ for _ , tc := range tests {
831+ t .Run (tc .name , func (t * testing.T ) {
832+ server := NewServer (false , tc .plugins , []framework.ResponseProcessor {})
833+ reqCtx := & RequestContext {
834+ Request : framework .NewInferenceRequest (),
835+ CycleState : framework .NewCycleState (),
836+ }
837+ for k , v := range tc .initialHeaders {
838+ reqCtx .Request .Headers [k ] = v
839+ }
840+
841+ bodyBytes , err := json .Marshal (map [string ]any {"model" : "test-model" , "prompt" : "test" })
842+ if err != nil {
843+ t .Fatalf ("Failed to marshal request body: %v" , err )
844+ }
845+
846+ resp , err := server .HandleRequestBody (ctx , reqCtx , bodyBytes )
847+ if err != nil {
848+ t .Fatalf ("HandleRequestBody returned unexpected error: %v" , err )
849+ }
850+
851+ want := buildNonStreamingResponse (tc .wantSetHeaders , tc .wantRemoved )
852+ envoytest .SortSetHeadersInResponses (want )
853+ envoytest .SortSetHeadersInResponses (resp )
854+
855+ if diff := cmp .Diff (want , resp , protocmp .Transform ()); diff != "" {
856+ t .Errorf ("HandleRequestBody response mismatch (-want +got):\n %s" , diff )
857+ }
858+ })
859+ }
860+ }
861+
862+ // buildNonStreamingResponse constructs the expected ProcessingResponse for a
863+ // non-streaming HandleRequestBody call with the given header mutations.
864+ func buildNonStreamingResponse (setHeaders map [string ]string , removeHeaders []string ) []* extProcPb.ProcessingResponse {
865+ setHeaderOpts := make ([]* basepb.HeaderValueOption , 0 , len (setHeaders ))
866+ for k , v := range setHeaders {
867+ setHeaderOpts = append (setHeaderOpts , & basepb.HeaderValueOption {
868+ Header : & basepb.HeaderValue {
869+ Key : k ,
870+ RawValue : []byte (v ),
871+ },
872+ })
873+ }
874+
875+ return []* extProcPb.ProcessingResponse {
876+ {
877+ Response : & extProcPb.ProcessingResponse_RequestBody {
878+ RequestBody : & extProcPb.BodyResponse {
879+ Response : & extProcPb.CommonResponse {
880+ ClearRouteCache : true ,
881+ HeaderMutation : & extProcPb.HeaderMutation {
882+ SetHeaders : setHeaderOpts ,
883+ RemoveHeaders : removeHeaders ,
884+ },
885+ },
886+ },
887+ },
888+ },
889+ }
890+ }
891+
892+ // === Request Body Tests (body mutations) ===
893+
597894type bodyMutatingPlugin struct {
598895 name string
599896 mutateFn func (ctx context.Context , cycleState * framework.CycleState , request * framework.InferenceRequest ) error
0 commit comments