Skip to content

fix(relay): detect upstream SSE when Content-Type header is missing (fixes #6075)#6254

Open
BenLiu104 wants to merge 1 commit into
QuantumNous:mainfrom
BenLiu104:fix/codex-sse-no-content-type
Open

fix(relay): detect upstream SSE when Content-Type header is missing (fixes #6075)#6254
BenLiu104 wants to merge 1 commit into
QuantumNous:mainfrom
BenLiu104:fix/codex-sse-no-content-type

Conversation

@BenLiu104

@BenLiu104 BenLiu104 commented Jul 16, 2026

Copy link
Copy Markdown

Summary

  • Fixes Bug: Codex (ChatGPT subscription) chat/completions→responses conversion fails with "invalid character 'e'" because upstream SSE has no Content-Type header #6075 — using a Codex (OpenAI /responses) channel via the /chat/completions adaptor returns status_code=500, invalid character 'e' looking for beginning of value.
  • Root cause: isResponsesEventStreamContentType() only inspects the Content-Type header. The ChatGPT Codex /responses endpoint returns a 200 SSE stream without a Content-Type: text/event-stream header, so new-api classified it as non-stream and passed the raw SSE body to the JSON handler, which failed on event: ...invalid character 'e'.
  • Fix: after the header check fails, sniff the body prefix one byte at a time. A leading event: / data: (after optional BOM/whitespace) is treated as SSE; a JSON-like start ({, [, ") is rejected early. Every consumed byte is preserved via a bufio.Reader (peekReadCloser) so downstream stream handlers still read the full original body.

Why incremental peeking (not Peek(64))

A fixed Reader.Peek(64) can block until 64 bytes arrive or EOF on a slow SSE prefix (e.g. event: ping\n\n). Reading one byte at a time recognizes the SSE prefix as soon as it appears and never blocks beyond the data actually sent.

Behavior

  • Upstream SSE with no Content-Type → correctly routed to the SSE handler.
  • Upstream JSON (normal chat completions) → unchanged, still treated as non-stream.

Verification

  • go build ./relay/... passes on main.
  • Standalone unit test of the sniff helper: 5/5 cases pass (Codex SSE event:/data: detected incl. BOM+whitespace; plain JSON {"..."} not mistaken for SSE).
  • Built a patched v1.0.0-rc.21 arm64 image and deployed to a live instance; the Codex channel now streams correctly through /chat/completions (issue reporter confirmed).

Test plan

  • Add a regression test in relay/ for isResponsesEventStreamSSEBody (SSE w/o Content-Type vs JSON).
  • Manually verify a Codex-type channel via /chat/completions with streaming enabled.

Summary by CodeRabbit

  • Bug Fixes
    • Improved compatibility with upstream streaming responses that omit the Content-Type header.
    • SSE streams are now correctly detected and processed instead of being misinterpreted as JSON.
    • Preserved response data and connection cleanup during stream detection.

Some backends (e.g. the ChatGPT Codex /responses endpoint) return a 200
SSE stream without a Content-Type: text/event-stream header. The existing
isResponsesEventStreamContentType() check only inspects the header, so
these responses were misclassified as non-stream and the SSE body was
handed to the JSON handler, producing:

  status_code=500 invalid character 'e' looking for beginning of value

After the header check fails, sniff the body prefix one byte at a time.
A leading "event:" or "data:" (after optional BOM/whitespace) is treated
as SSE; a JSON-like start ({, [, ") is rejected early. Every consumed
byte is preserved via a bufio.Reader so downstream stream handlers still
read the full original body.

Fixes QuantumNous#6075
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The Responses relay now detects SSE streams when upstream responses omit Content-Type. It probes for event: or data: prefixes, rejects JSON-like bodies, restores consumed bytes, and preserves the original body’s close behavior.

Changes

Responses SSE detection

Layer / File(s) Summary
SSE detection and body preservation
relay/chat_completions_via_responses.go
The relay probes response prefixes for SSE markers and wraps the body so downstream handlers receive all original bytes while retaining the original Close() behavior.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related issues

  • QuantumNous/new-api issue 6075: The change directly implements SSE body sniffing and byte-preserving response handling when Content-Type is absent.

Possibly related PRs

Suggested reviewers: calcium-ion

Poem

I sniffed the stream, said “SSE!” with cheer,
And kept each byte safely near.
No JSON mix-up, no bytes astray,
The relay hops along its way.
- A helpful rabbit 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fix: detecting upstream SSE streams when the Content-Type header is missing.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with 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.

Inline comments:
In `@relay/chat_completions_via_responses.go`:
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1016a84b-f98d-483e-955a-c50a6d827572

📥 Commits

Reviewing files that changed from the base of the PR and between a63364d and 7171981.

📒 Files selected for processing (1)
  • relay/chat_completions_via_responses.go

Comment on lines +212 to +229
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
}
}

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant