Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions internal/bodymutator/body_mutator.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ func NewBodyMutator(bodyMutations *filterapi.HTTPBodyMutation, originalBody []by
}
}

// HasMutations reports whether this BodyMutator was constructed with a
// non-nil HTTPBodyMutation config. When false, Mutate is a no-op that
// returns its input unchanged, so callers can short-circuit the
// upstream filter's body-replacement path entirely.
func (b *BodyMutator) HasMutations() bool {
return b != nil && b.bodyMutations != nil
}

// isJSONValue checks if a string represents a JSON value (not a plain string)
func isJSONValue(value string) bool {
value = strings.TrimSpace(value)
Expand Down
31 changes: 29 additions & 2 deletions internal/extproc/processor_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,8 +366,19 @@ func (u *upstreamProcessor[ReqT, RespT, RespChunkT, EndpointSpecT]) ProcessReque
}
}

// Apply body mutations from the route and also restore original body on retry.
bodyMutation = applyBodyMutation(u.bodyMutator, bodyMutation, u.parent.originalRequestBodyRaw, u.logger)
// Decide whether the upstream filter should replace the request body at
// all. If the translator emitted no body, no backend HTTPBodyMutation is
// configured, and we're not forcing body replay (retry or
// streaming-without-usage), then issuing CONTINUE_AND_REPLACE with the
// captured original body would clobber any body mutation applied by an
// earlier ext_proc filter in the chain.
mutatorHasMutations := u.bodyMutator != nil && u.bodyMutator.HasMutations()
wantBodyReplace := bodyMutation != nil || forceBodyMutation || mutatorHasMutations

if wantBodyReplace {
// Apply body mutations from the route and also restore original body on retry.
bodyMutation = applyBodyMutation(u.bodyMutator, bodyMutation, u.parent.originalRequestBodyRaw, u.logger)
}

// Ensure bodyMutation is not nil for subsequent processing
if bodyMutation == nil {
Expand All @@ -392,6 +403,22 @@ func (u *upstreamProcessor[ReqT, RespT, RespChunkT, EndpointSpecT]) ProcessReque
}
}

if !wantBodyReplace {
// No body change -> no content-length restamp; emit CONTINUE so Envoy
// keeps whatever body the previous filter in the chain produced.
return &extprocv3.ProcessingResponse{
Response: &extprocv3.ProcessingResponse_RequestHeaders{
RequestHeaders: &extprocv3.HeadersResponse{
Response: &extprocv3.CommonResponse{
HeaderMutation: headerMutation,
Status: extprocv3.CommonResponse_CONTINUE,
},
},
},
DynamicMetadata: buildRequestHeaderDynamicMetadata(u.requestHeaders),
}, nil
}

var dm *structpb.Struct
if bm := bodyMutation.GetBody(); bm != nil {
dm = buildContentLengthDynamicMetadataOnRequest(len(bm))
Expand Down
122 changes: 122 additions & 0 deletions internal/extproc/processor_impl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (

anthropicschema "github.com/envoyproxy/ai-gateway/internal/apischema/anthropic"
"github.com/envoyproxy/ai-gateway/internal/apischema/openai"
"github.com/envoyproxy/ai-gateway/internal/bodymutator"
"github.com/envoyproxy/ai-gateway/internal/endpointspec"
"github.com/envoyproxy/ai-gateway/internal/filterapi"
"github.com/envoyproxy/ai-gateway/internal/headermutator"
Expand Down Expand Up @@ -744,6 +745,127 @@ func Test_messagesProcessorUpstreamFilter_ProcessRequestHeaders_AWSAnthropicBeta
require.Equal(t, []any{"interleaved-thinking-2025-05-14", "context-1m-2025-08-07"}, betaValues)
}

// Test_chatCompletionProcessorUpstreamFilter_ProcessRequestHeaders_BodyReplaceContract
// locks the contract for when the upstream filter must NOT replace the request
// body: when the translator returns no body, no backend HTTPBodyMutation is
// configured, and forceBodyMutation is false, the upstream filter must emit
// CONTINUE rather than CONTINUE_AND_REPLACE. Issuing CONTINUE_AND_REPLACE with
// the captured original body would clobber any body mutation applied by an
// earlier ext_proc filter in the chain. Header mutations and auth headers
// must still apply on the CONTINUE branch.
//
// The sibling subtest pins the opposite half of the contract: when the
// translator DID emit a body, the upstream filter must continue to issue
// CONTINUE_AND_REPLACE, so a future refactor cannot quietly flip the contract
// back.
func Test_chatCompletionProcessorUpstreamFilter_ProcessRequestHeaders_BodyReplaceContract(t *testing.T) {
t.Run("no translator body, no mutator, no force -> CONTINUE", func(t *testing.T) {
someBody := bodyFromModel(t, "some-model", false, nil)
headers := map[string]string{
":path": "/foo",
internalapi.ModelNameHeaderKeyDefault: "some-model",
}
var expBody openai.ChatCompletionRequest
require.NoError(t, json.Unmarshal(someBody, &expBody))

pathRewrite := []internalapi.Header{{":path", "/v1/chat/completions"}}
mt := &mockTranslator{
t: t,
expRequestBody: &expBody,
retHeaderMutation: pathRewrite,
retBodyMutation: nil,
expForceRequestBodyMutation: false,
}
mm := &mockMetrics{}
p := &chatCompletionProcessorUpstreamFilter{
parent: &chatCompletionProcessorRouterFilter{
config: &filterapi.RuntimeConfig{},
logger: slog.Default(),
originalRequestBodyRaw: someBody,
originalRequestBody: &expBody,
originalModel: "some-model",
stream: false,
forceBodyMutation: false,
},
requestHeaders: headers,
metrics: mm,
translator: mt,
handler: &mockBackendAuthHandler{},
// No-config body mutator: HasMutations() returns false. This mirrors
// SetBackend's call to bodymutator.NewBodyMutator(nil, ...) when the
// route has no HTTPBodyMutation.
bodyMutator: bodymutator.NewBodyMutator(nil, someBody),
}

resp, err := p.ProcessRequestHeaders(t.Context(), nil)
require.NoError(t, err)
require.NotNil(t, resp)

commonRes := resp.Response.(*extprocv3.ProcessingResponse_RequestHeaders).RequestHeaders.Response
require.Equal(t, extprocv3.CommonResponse_CONTINUE, commonRes.Status,
"must NOT issue CONTINUE_AND_REPLACE when nothing actually needs to mutate the body — that path silently replays the original body and clobbers earlier filters' mutations")
require.Nil(t, commonRes.BodyMutation, "no body mutation should ride on a CONTINUE response")

require.NotNil(t, commonRes.HeaderMutation)
require.Len(t, commonRes.HeaderMutation.SetHeaders, 2,
"header mutations from the translator (path rewrite) and the auth handler must still apply on the CONTINUE branch")
require.Equal(t, ":path", commonRes.HeaderMutation.SetHeaders[0].Header.Key)
require.Equal(t, []byte("/v1/chat/completions"), commonRes.HeaderMutation.SetHeaders[0].Header.RawValue)
require.Equal(t, "foo", commonRes.HeaderMutation.SetHeaders[1].Header.Key)
require.Equal(t, "mock-auth-handler", string(commonRes.HeaderMutation.SetHeaders[1].Header.RawValue))

// No body change -> no content-length restamp.
// buildRequestHeaderDynamicMetadata returns nil when LogRequestHeaderAttributes is empty.
require.Nil(t, resp.DynamicMetadata,
"buildContentLengthDynamicMetadataOnRequest must not be called when the body is not replaced")
})

t.Run("translator body present -> CONTINUE_AND_REPLACE", func(t *testing.T) {
someBody := bodyFromModel(t, "some-model", false, nil)
headers := map[string]string{
":path": "/foo",
internalapi.ModelNameHeaderKeyDefault: "some-model",
}
var expBody openai.ChatCompletionRequest
require.NoError(t, json.Unmarshal(someBody, &expBody))

bodyMut := []byte("translator-emitted body")
mt := &mockTranslator{
t: t,
expRequestBody: &expBody,
retHeaderMutation: []internalapi.Header{{":path", "/v1/chat/completions"}},
retBodyMutation: bodyMut,
expForceRequestBodyMutation: false,
}
mm := &mockMetrics{}
p := &chatCompletionProcessorUpstreamFilter{
parent: &chatCompletionProcessorRouterFilter{
config: &filterapi.RuntimeConfig{},
logger: slog.Default(),
originalRequestBodyRaw: someBody,
originalRequestBody: &expBody,
originalModel: "some-model",
stream: false,
forceBodyMutation: false,
},
requestHeaders: headers,
metrics: mm,
translator: mt,
handler: &mockBackendAuthHandler{},
bodyMutator: bodymutator.NewBodyMutator(nil, someBody),
}

resp, err := p.ProcessRequestHeaders(t.Context(), nil)
require.NoError(t, err)
require.NotNil(t, resp)

commonRes := resp.Response.(*extprocv3.ProcessingResponse_RequestHeaders).RequestHeaders.Response
require.Equal(t, extprocv3.CommonResponse_CONTINUE_AND_REPLACE, commonRes.Status,
"existing CONTINUE_AND_REPLACE behavior must be preserved when the translator produced a body")
require.Equal(t, bodyMut, commonRes.BodyMutation.GetBody())
})
}

func Test_chatCompletionProcessorUpstreamFilter_MergeWithTokenLatencyMetadata(t *testing.T) {
t.Run("empty metadata", func(t *testing.T) {
mm := &mockMetrics{}
Expand Down