Skip to content

Commit 5f2053c

Browse files
committed
feat: add ResponseChunkProcessor interface for streaming response processing
Split response processing into two interfaces per review feedback: - ResponseProcessor: processes the complete buffered response body (existing) - ResponseChunkProcessor: processes individual chunks without buffering (new) The Profile now carries both processor lists and a NeedsResponseBuffering flag. The config loader sorts response plugins into the right list based on which interface they implement. A plugin can implement both. When ResponseProcessor plugins are present → buffer the full response. When only ResponseChunkProcessors are present → stream chunks through. When neither is present → buffer (backward compatible). Signed-off-by: Noy Itzikowitz <nitzikow@redhat.com>
1 parent ac26114 commit 5f2053c

5 files changed

Lines changed: 114 additions & 20 deletions

File tree

pkg/config/loader/configloader.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,7 @@ func buildProfiles(rawProfiles []configapi.Profile, handle plugin.Handle) (map[s
214214
return nil, fmt.Errorf("the profile %s must have one or both of the Request and Response sections", rawProfile.Name)
215215
}
216216

217-
theProfile := requesthandling.Profile{
218-
ResponsePlugins: make([]requesthandling.ResponseProcessor, len(rawProfile.Plugins.Response)),
219-
}
217+
theProfile := requesthandling.Profile{}
220218

221219
for _, pluginRef := range rawProfile.Plugins.Request {
222220
rawPlugin := handle.Plugin(pluginRef.PluginRef)
@@ -245,17 +243,25 @@ func buildProfiles(rawProfiles []configapi.Profile, handle plugin.Handle) (map[s
245243
}
246244
}
247245

248-
for idx, pluginRef := range rawProfile.Plugins.Response {
246+
for _, pluginRef := range rawProfile.Plugins.Response {
249247
rawPlugin := handle.Plugin(pluginRef.PluginRef)
250248
if rawPlugin == nil {
251249
return nil, fmt.Errorf("there is no plugin named %s", pluginRef.PluginRef)
252250
}
253-
thePlugin, ok := rawPlugin.(requesthandling.ResponseProcessor)
254-
if !ok {
255-
return nil, fmt.Errorf("the plugin named %s is not a ResponseProcessor", pluginRef.PluginRef)
251+
matched := false
252+
if bodyPlugin, ok := rawPlugin.(requesthandling.ResponseProcessor); ok {
253+
theProfile.ResponsePlugins = append(theProfile.ResponsePlugins, bodyPlugin)
254+
matched = true
255+
}
256+
if chunkPlugin, ok := rawPlugin.(requesthandling.ResponseChunkProcessor); ok {
257+
theProfile.ResponseChunkProcessors = append(theProfile.ResponseChunkProcessors, chunkPlugin)
258+
matched = true
259+
}
260+
if !matched {
261+
return nil, fmt.Errorf("the plugin named %s is not a ResponseProcessor or ResponseChunkProcessor", pluginRef.PluginRef)
256262
}
257-
theProfile.ResponsePlugins[idx] = thePlugin
258263
}
264+
theProfile.NeedsResponseBuffering = len(theProfile.ResponsePlugins) > 0
259265

260266
profiles[rawProfile.Name] = &theProfile
261267
}

pkg/framework/interface/requesthandling/plugins.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,22 @@ type RequestProcessor interface {
4343
ProcessRequest(ctx context.Context, cycleState *plugin.CycleState, request *InferenceRequest) error
4444
}
4545

46+
// ResponseProcessor processes the complete buffered response body.
47+
// If any plugin in a profile implements this interface, the framework buffers
48+
// the entire response before calling ProcessResponse on each such plugin.
4649
type ResponseProcessor interface {
4750
plugin.Plugin
48-
// ProcessResponse runs the ResponseProcessor plugin.
49-
// ResponseProcessor can mutate the headers and/or the body of the response.
5051
ProcessResponse(ctx context.Context, cycleState *plugin.CycleState, response *InferenceResponse) error
5152
}
5253

54+
// ResponseChunkProcessor processes individual response body chunks as they
55+
// stream through without buffering. The framework calls ProcessResponseChunk
56+
// for each chunk; the returned bytes are forwarded to the client.
57+
type ResponseChunkProcessor interface {
58+
plugin.Plugin
59+
ProcessResponseChunk(ctx context.Context, cycleState *plugin.CycleState, chunk []byte, isFinal bool) ([]byte, error)
60+
}
61+
5362
type PostProcessor interface {
5463
plugin.Plugin
5564

pkg/framework/interface/requesthandling/types.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,14 @@ type Profile struct {
114114
// RequestPlugins are the request processing plugin instances executed by the request handler,
115115
// in the same order provided in the configuration file.
116116
RequestPlugins []RequestProcessor
117-
// ResponsePlugins are the response processing plugin instances executed by the response handler,
118-
// in the same order provided in the configuration file.
117+
// ResponsePlugins process the complete buffered response body.
119118
ResponsePlugins []ResponseProcessor
119+
// ResponseChunkProcessors process individual response chunks without buffering.
120+
ResponseChunkProcessors []ResponseChunkProcessor
121+
// NeedsResponseBuffering is true when any ResponsePlugin is present.
122+
// The framework uses this to decide whether to buffer the full response body
123+
// or stream chunks through ResponseChunkProcessors.
124+
NeedsResponseBuffering bool
120125
// ModelSelectorPlugins are the Filter, Scorer (including WeightedScorer), and Picker plugin
121126
// instances to be wired into any model-selector plugin present in RequestPlugins.
122127
ModelSelectorPlugins []plugin.Plugin

pkg/handlers/response.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,56 @@ func (s *Server) generateEmptyResponseBodyResponse(responseBodyBytes []byte) []*
131131
return responses
132132
}
133133

134+
// buildChunkPassthroughResponse wraps a single response body chunk in the
135+
// ext_proc streaming response format. Used when no ResponseProcessor (buffered)
136+
// plugins are present — chunks flow through ResponseChunkProcessors and are
137+
// forwarded immediately.
138+
func (s *Server) buildChunkPassthroughResponse(chunk []byte, isFinal bool) []*eppb.ProcessingResponse {
139+
if isFinal {
140+
return []*eppb.ProcessingResponse{
141+
{
142+
Response: &eppb.ProcessingResponse_ResponseHeaders{
143+
ResponseHeaders: &eppb.HeadersResponse{},
144+
},
145+
},
146+
{
147+
Response: &eppb.ProcessingResponse_ResponseBody{
148+
ResponseBody: &eppb.BodyResponse{
149+
Response: &eppb.CommonResponse{
150+
BodyMutation: &eppb.BodyMutation{
151+
Mutation: &eppb.BodyMutation_StreamedResponse{
152+
StreamedResponse: &eppb.StreamedBodyResponse{
153+
Body: chunk,
154+
EndOfStream: true,
155+
},
156+
},
157+
},
158+
},
159+
},
160+
},
161+
},
162+
}
163+
}
164+
return []*eppb.ProcessingResponse{
165+
{
166+
Response: &eppb.ProcessingResponse_ResponseBody{
167+
ResponseBody: &eppb.BodyResponse{
168+
Response: &eppb.CommonResponse{
169+
BodyMutation: &eppb.BodyMutation{
170+
Mutation: &eppb.BodyMutation_StreamedResponse{
171+
StreamedResponse: &eppb.StreamedBodyResponse{
172+
Body: chunk,
173+
EndOfStream: false,
174+
},
175+
},
176+
},
177+
},
178+
},
179+
},
180+
},
181+
}
182+
}
183+
134184
// HandleResponseTrailers handles response trailers.
135185
func (s *Server) HandleResponseTrailers(trailers *eppb.HttpTrailers) ([]*eppb.ProcessingResponse, error) {
136186
return []*eppb.ProcessingResponse{

pkg/handlers/server.go

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -176,15 +176,39 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error {
176176
if reqCtx.ResponseFirstChunkTimestamp.IsZero() {
177177
reqCtx.ResponseFirstChunkTimestamp = time.Now()
178178
}
179-
responseBody = append(responseBody, v.ResponseBody.Body...)
180-
if !v.ResponseBody.EndOfStream {
181-
continue
179+
180+
hasChunkProcessors := reqCtx.Profile != nil && len(reqCtx.Profile.ResponseChunkProcessors) > 0
181+
needsBuffering := !hasChunkProcessors
182+
if reqCtx.Profile != nil && reqCtx.Profile.NeedsResponseBuffering {
183+
needsBuffering = true
184+
}
185+
if needsBuffering {
186+
responseBody = append(responseBody, v.ResponseBody.Body...)
187+
if !v.ResponseBody.EndOfStream {
188+
continue
189+
}
190+
reqCtx.ResponseCompleteTimestamp = time.Now()
191+
model, _ := reqCtx.Request.Body["model"].(string)
192+
metrics.RecordRequestTTFT(model, reqCtx.ResponseFirstChunkTimestamp.Sub(reqCtx.RequestReceivedTimestamp))
193+
responses, err = s.HandleResponseBody(ctx, reqCtx, responseBody)
194+
loggerVerbose.Info("processing response body complete")
195+
} else {
196+
chunk := v.ResponseBody.Body
197+
isFinal := v.ResponseBody.EndOfStream
198+
if reqCtx.Profile != nil {
199+
for _, cp := range reqCtx.Profile.ResponseChunkProcessors {
200+
chunk, err = cp.ProcessResponseChunk(ctx, reqCtx.CycleState, chunk, isFinal)
201+
if err != nil {
202+
break
203+
}
204+
}
205+
}
206+
if isFinal {
207+
reqCtx.ResponseCompleteTimestamp = time.Now()
208+
}
209+
responses = s.buildChunkPassthroughResponse(chunk, isFinal)
210+
loggerVerbose.Info("response body streaming pass-through complete")
182211
}
183-
reqCtx.ResponseCompleteTimestamp = time.Now()
184-
model, _ := reqCtx.Request.Body["model"].(string)
185-
metrics.RecordRequestTTFT(model, reqCtx.ResponseFirstChunkTimestamp.Sub(reqCtx.RequestReceivedTimestamp))
186-
responses, err = s.HandleResponseBody(ctx, reqCtx, responseBody)
187-
loggerVerbose.Info("processing response body complete")
188212
case *extProcPb.ProcessingRequest_ResponseTrailers:
189213
responses, err = s.HandleResponseTrailers(v.ResponseTrailers)
190214
default:

0 commit comments

Comments
 (0)