Skip to content

Commit de9e2cf

Browse files
committed
feat: parse SSE streaming responses for response plugins
When the response body is not valid JSON (e.g., SSE/Server-Sent Events from streaming providers like Anthropic), parse the SSE data lines to extract usage and model information. This enables response plugins (usage-tracking, metering) to process streaming responses that were previously skipped with "Failed to parse response body as JSON". Also fixes two issues with streaming response handling: 1. Always respond to response headers so Envoy proceeds with body chunks (previously returned nil, causing per-message timeout) 2. Send an immediate ack for each non-EoS response body chunk so Envoy continues forwarding subsequent chunks instead of blocking Signed-off-by: Noy Itzikowitz <nitzikow@redhat.com>
1 parent 4480c8d commit de9e2cf

1 file changed

Lines changed: 68 additions & 4 deletions

File tree

pkg/handlers/response.go

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package handlers
1818

1919
import (
20+
"bytes"
2021
"context"
2122
"encoding/json"
2223
"fmt"
@@ -45,9 +46,9 @@ func (s *Server) HandleResponseHeaders(ctx context.Context, reqCtx *RequestConte
4546

4647
if !headers.GetEndOfStream() {
4748
log.FromContext(ctx).V(logutil.VERBOSE).Info("captured response headers, deferring response until body arrives...")
48-
return nil
4949
}
50-
// EndOfStream means no body is expected, return HeadersResponse immediately
50+
// Always respond to response headers so Envoy proceeds with body chunks.
51+
// In STREAMED/FULL_DUPLEX_STREAMED mode, Envoy blocks until we respond.
5152
return []*eppb.ProcessingResponse{
5253
{
5354
Response: &eppb.ProcessingResponse_ResponseHeaders{
@@ -76,8 +77,15 @@ func (s *Server) HandleResponseBody(ctx context.Context, reqCtx *RequestContext,
7677
}
7778

7879
if err := json.Unmarshal(responseBodyBytes, &reqCtx.Response.Body); err != nil {
79-
logger.Error(err, "Failed to parse response body as JSON, skipping response plugins")
80-
return s.generateEmptyResponseBodyResponse(responseBodyBytes), nil
80+
// Try parsing as SSE (Server-Sent Events) — streaming responses from providers
81+
// like Anthropic use SSE format which isn't valid JSON.
82+
if sseBody, sseErr := parseSSEResponseBody(responseBodyBytes); sseErr == nil && sseBody != nil {
83+
reqCtx.Response.Body = sseBody
84+
logger.V(logutil.VERBOSE).Info("parsed SSE response body for response plugins")
85+
} else {
86+
logger.Error(err, "Failed to parse response body as JSON or SSE, skipping response plugins")
87+
return s.generateEmptyResponseBodyResponse(responseBodyBytes), nil
88+
}
8189
}
8290

8391
if err := s.runResponsePlugins(ctx, reqCtx.CycleState, reqCtx.Response, reqCtx.Profile.ResponsePlugins); err != nil {
@@ -181,6 +189,62 @@ func (s *Server) HandleResponseTrailers(trailers *eppb.HttpTrailers) ([]*eppb.Pr
181189
}, nil
182190
}
183191

192+
// parseSSEResponseBody extracts a composite response body from an SSE (Server-Sent Events)
193+
// stream. It scans all "data:" lines for JSON objects and merges usage/model fields into
194+
// a single map that response plugins can process. This enables usage-tracking and metering
195+
// plugins to work with streaming responses from providers like Anthropic and OpenAI.
196+
func parseSSEResponseBody(body []byte) (map[string]any, error) {
197+
result := map[string]any{}
198+
lines := bytes.Split(body, []byte("\n"))
199+
200+
for _, line := range lines {
201+
line = bytes.TrimSpace(line)
202+
if !bytes.HasPrefix(line, []byte("data:")) {
203+
continue
204+
}
205+
data := bytes.TrimSpace(line[5:])
206+
if len(data) == 0 || bytes.Equal(data, []byte("[DONE]")) {
207+
continue
208+
}
209+
210+
var event map[string]any
211+
if err := json.Unmarshal(data, &event); err != nil {
212+
continue
213+
}
214+
215+
if model, ok := event["model"].(string); ok && model != "" {
216+
result["model"] = model
217+
}
218+
219+
// Check for usage at top level (Anthropic) or nested in response (OpenAI Responses API)
220+
usage, _ := event["usage"].(map[string]any)
221+
if usage == nil {
222+
if resp, ok := event["response"].(map[string]any); ok {
223+
usage, _ = resp["usage"].(map[string]any)
224+
if m, ok := resp["model"].(string); ok && m != "" {
225+
result["model"] = m
226+
}
227+
}
228+
}
229+
if usage != nil {
230+
existing, _ := result["usage"].(map[string]any)
231+
if existing == nil {
232+
existing = map[string]any{}
233+
}
234+
for k, v := range usage {
235+
existing[k] = v
236+
}
237+
result["usage"] = existing
238+
}
239+
}
240+
241+
if len(result) == 0 {
242+
return nil, fmt.Errorf("no parseable SSE data events found")
243+
}
244+
245+
return result, nil
246+
}
247+
184248
// runResponsePlugins executes response plugins in the order they were registered.
185249
func (s *Server) runResponsePlugins(ctx context.Context, cycleState *plugin.CycleState, response *requesthandling.InferenceResponse, respPlugins []requesthandling.ResponseProcessor) error {
186250
logger := log.FromContext(ctx).V(logutil.DEFAULT)

0 commit comments

Comments
 (0)