Skip to content
Open
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
61 changes: 61 additions & 0 deletions relay/chat_completions_via_responses.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package relay

import (
"bufio"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -151,6 +152,13 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad
httpResp = resp.(*http.Response)
clientStream := info.IsStream
upstreamStream := isResponsesEventStreamContentType(httpResp.Header.Get("Content-Type"))
if !upstreamStream {
// Some backends (e.g. the ChatGPT Codex /responses endpoint) return a
// 200 SSE stream with no Content-Type header at all. The header check
// above misses this and the caller then treats the SSE body as JSON,
// failing with "invalid character 'e'". Sniff the body prefix instead.
upstreamStream = isResponsesEventStreamSSEBody(httpResp.Body, &httpResp.Body)
}
info.IsStream = clientStream || upstreamStream
if httpResp.StatusCode != http.StatusOK {
newApiErr := service.RelayErrorHandler(c.Request.Context(), httpResp, false)
Expand Down Expand Up @@ -187,3 +195,56 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad
func isResponsesEventStreamContentType(contentType string) bool {
return strings.Contains(strings.ToLower(contentType), "text/event-stream")
}

// isResponsesEventStreamSSEBody detects an SSE response body (a leading
// "event:" or "data:" prefix, after an optional BOM and/or whitespace) when
// the upstream omits the Content-Type header. It peeks the body one byte at a
// time so a short live SSE prefix (e.g. "event: ping\n\n") is recognized before
// EOF, and it restores every consumed byte via a fresh bufio.Reader so the
// downstream stream handlers receive the full original body. JSON-like bodies
// (leading '{', '[', '"') are rejected early. The (possibly repopulated) body
// is written back through out; the caller must use out for subsequent reads.
func isResponsesEventStreamSSEBody(rc io.ReadCloser, out *io.ReadCloser) bool {
if rc == nil {
return false
}
br := bufio.NewReader(rc)
buf := make([]byte, 0, 64)
for len(buf) < cap(buf) {
b, err := br.ReadByte()
if err != nil {
break // EOF or read error: not enough to confirm SSE
}
buf = append(buf, b)
trimmed := strings.TrimPrefix(string(buf), "\xef\xbb\xbf")
trimmed = strings.TrimLeft(trimmed, " \r\n")
if strings.HasPrefix(trimmed, "event:") || strings.HasPrefix(trimmed, "data:") {
*out = &peekReadCloser{Reader: br, closer: rc}
return true
}
// JSON-like start → definitely not SSE, stop probing.
if len(buf) >= 1 && (buf[0] == '{' || buf[0] == '[' || buf[0] == '"') {
break
}
}
Comment on lines +212 to +229

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Restore consumed bytes and prevent blocking on padded JSON.

bufio.Reader.ReadByte() consumes bytes from the underlying buffer. Because peekReadCloser only wraps br without prepending the read bytes, the initial prefix read into buf is permanently lost to downstream parsers, resulting in stream corruption.

Additionally, checking buf[0] instead of trimmed[0] for JSON characters bypasses the early exit if the response starts with whitespace, which could cause the loop to block and wait for 64 bytes on a slow padded JSON stream.

Replacing the manual byte-read loop with br.Peek(i) solves both issues: Peek reads data without advancing the internal offset (safely preserving all bytes for peekReadCloser), and checking trimmed[0] guarantees a fast exit for padded JSON.

🛠️ Proposed fix
-	buf := make([]byte, 0, 64)
-	for len(buf) < cap(buf) {
-		b, err := br.ReadByte()
-		if err != nil {
-			break // EOF or read error: not enough to confirm SSE
-		}
-		buf = append(buf, b)
-		trimmed := strings.TrimPrefix(string(buf), "\xef\xbb\xbf")
-		trimmed = strings.TrimLeft(trimmed, " 	\r\n")
+	for i := 1; i <= 64; i++ {
+		p, err := br.Peek(i)
+		
+		trimmed := strings.TrimPrefix(string(p), "\xef\xbb\xbf")
+		trimmed = strings.TrimLeft(trimmed, " \t\r\n")
 		if strings.HasPrefix(trimmed, "event:") || strings.HasPrefix(trimmed, "data:") {
 			*out = &peekReadCloser{Reader: br, closer: rc}
 			return true
 		}
 		// JSON-like start → definitely not SSE, stop probing.
-		if len(buf) >= 1 && (buf[0] == '{' || buf[0] == '[' || buf[0] == '"') {
+		if len(trimmed) >= 1 && (trimmed[0] == '{' || trimmed[0] == '[' || trimmed[0] == '"') {
 			break
 		}
+		if err != nil {
+			break // EOF or read error: not enough to confirm SSE
+		}
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
buf := make([]byte, 0, 64)
for len(buf) < cap(buf) {
b, err := br.ReadByte()
if err != nil {
break // EOF or read error: not enough to confirm SSE
}
buf = append(buf, b)
trimmed := strings.TrimPrefix(string(buf), "\xef\xbb\xbf")
trimmed = strings.TrimLeft(trimmed, " \r\n")
if strings.HasPrefix(trimmed, "event:") || strings.HasPrefix(trimmed, "data:") {
*out = &peekReadCloser{Reader: br, closer: rc}
return true
}
// JSON-like start → definitely not SSE, stop probing.
if len(buf) >= 1 && (buf[0] == '{' || buf[0] == '[' || buf[0] == '"') {
break
}
}
for i := 1; i <= 64; i++ {
p, err := br.Peek(i)
trimmed := strings.TrimPrefix(string(p), "\xef\xbb\xbf")
trimmed = strings.TrimLeft(trimmed, " \t\r\n")
if strings.HasPrefix(trimmed, "event:") || strings.HasPrefix(trimmed, "data:") {
*out = &peekReadCloser{Reader: br, closer: rc}
return true
}
// JSON-like start → definitely not SSE, stop probing.
if len(trimmed) >= 1 && (trimmed[0] == '{' || trimmed[0] == '[' || trimmed[0] == '"') {
break
}
if err != nil {
break // EOF or read error: not enough to confirm SSE
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@relay/chat_completions_via_responses.go` around lines 212 - 229, Update the
SSE probing logic around peekReadCloser to use br.Peek(i) rather than consuming
bytes with ReadByte, ensuring all inspected bytes remain available to downstream
parsers. Apply BOM and whitespace trimming to the peeked data, check the first
trimmed byte for JSON-like starts to exit promptly on padded JSON, and preserve
the existing SSE detection and fallback behavior.

// Not SSE (or inconclusive): hand the still-buffered body back so the
// non-stream JSON handler reads the complete original response.
*out = &peekReadCloser{Reader: br, closer: rc}
return false
}

// peekReadCloser wraps a bufio.Reader plus the original closer so downstream
// consumers read from the buffer (which still holds every byte) and close
// correctly.
type peekReadCloser struct {
Reader *bufio.Reader
closer io.ReadCloser
}

func (p *peekReadCloser) Read(b []byte) (int, error) {
return p.Reader.Read(b)
}

func (p *peekReadCloser) Close() error {
return p.closer.Close()
}