Skip to content

fix(api): restore SSE Content-Type for streaming responses#82

Merged
koitococo merged 3 commits intomainfrom
fix/sse-content-type
May 4, 2026
Merged

fix(api): restore SSE Content-Type for streaming responses#82
koitococo merged 3 commits intomainfrom
fix/sse-content-type

Conversation

@pescn
Copy link
Copy Markdown
Contributor

@pescn pescn commented May 4, 2026

Summary

  • All three streaming endpoints (/v1/chat/completions, /v1/responses, /v1/messages) were returning Content-Type: text/plain instead of text/event-stream. Bodies were correctly SSE-formatted but the header lied, breaking strict SSE clients (browser EventSource, EvalScope perf benchmark, any RFC 8941 implementation).
  • Switch each streaming branch to return a native Response wrapping a ReadableStream. Elysia detects pre-formatted Responses and runs handleStream with skipFormat=true, preserving our headers and bypassing 1.4's auto SSE wrapping.
  • Add Accept: text/event-stream content negotiation: when body.stream is unset, an Accept header listing text/event-stream enables streaming. Body always wins when explicit.

Root cause

When deps were bumped from elysia ^1.3.5 to ^1.4.16 (d1f186a), the default Content-Type for async-generator returns silently changed from text/event-stream to text/plain. In 1.4 the auto SSE path kicks in only when set.headers[\"content-type\"] (lowercase) starts with text/event-stream, and it then wraps every yielded chunk with data: \${chunk}\n\n. Our adapters already emit pre-formatted SSE strings, so opting in to that auto path would double-prefix data:. Setting set.headers[\"Content-Type\"] (mixed case) leaves both keys live and Bun joins them as text/event-stream, text/plain. Returning a native Response is the only path in 1.4 that lets us set the header without triggering double-wrapping.

Verified against three historical versions:

Version Date Elysia stream:true Content-Type
eebe5a2 2025-09-09 ^1.3.5 text/event-stream
3223b76 2025-11-20 ^1.4.16 text/plain
main (this PR) now ^1.4.22 text/event-stream; charset=utf-8

Test plan

  • curl -D - against /v1/chat/completions with stream:trueContent-Type: text/event-stream; charset=utf-8, body is valid SSE ending in data: [DONE]\n\n
  • Same for /v1/messages (Anthropic) → ends with event: message_stop
  • Same for /v1/responses (OpenAI Responses API) → events stream correctly
  • Non-streaming branches still return Content-Type: application/json (regression check)
  • Accept: text/event-stream (no body.stream) → streaming
  • Accept: text/event-stream + stream:false → JSON (body wins)
  • Weighted Accept list (text/event-stream;q=1, application/json;q=0.5) → streaming
  • bun run lint and bun run check pass

Closes #81

Summary by CodeRabbit

发行说明

  • 新功能
    • 自动根据客户端 Accept 头启用事件流(SSE)传输。
    • 新增用于识别是否接受 SSE 的解析工具函数。
  • 改进
    • 流式响应改为原生 SSE 响应,显式设置 Content-Type/连接/缓存头,提升稳定性与兼容性。
    • 流内错误改为通过 SSE 事件发送并记录,改善错误可见性与连接处理。
  • 测试
    • 新增单元测试覆盖 Accept 头解析与相关行为。

All three streaming endpoints (/v1/chat/completions, /v1/responses,
/v1/messages) were returning Content-Type: text/plain instead of
text/event-stream, breaking strict SSE clients (browser EventSource,
EvalScope perf benchmark, any RFC 8941 implementation). Bodies were
correctly SSE-formatted but the header lied.

Root cause: when deps were bumped from elysia 1.3 → 1.4, the default
Content-Type for async-generator returns silently changed from
text/event-stream to text/plain. In 1.4 the auto SSE path triggers
only when set.headers["content-type"] (lowercase) starts with
"text/event-stream", and it then wraps every yielded chunk with
"data: ${chunk}\n\n" — but our adapters already emit pre-formatted
SSE strings, so opting in to the auto path would double-prefix data:.
Setting set.headers["Content-Type"] (mixed case) leaves both keys
live and Bun joins them as "text/event-stream, text/plain".

Fix: return a native Response wrapping a ReadableStream. Elysia's
mapResponse detects a pre-formatted Response with chunked transfer
encoding and runs handleStream with skipFormat=true, which preserves
our headers and bypasses the auto SSE wrapping
(node_modules/elysia/dist/adapter/utils.mjs:209-215).

Also wire Accept: text/event-stream content negotiation: when the
request body does not specify `stream`, an Accept header listing
text/event-stream now enables streaming. Body always wins when
explicit, so existing OpenAI-compatible behavior is preserved.

Verified with curl against a live server:
- stream:true → Content-Type: text/event-stream; charset=utf-8
- Accept: text/event-stream (no body.stream) → streaming
- stream:false + Accept: text/event-stream → JSON (body wins)
- non-streaming (default) → application/json (regression check)

Closes #81
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 4, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: ea463e23-e5d9-470e-b9d4-01636cc5277e

📥 Commits

Reviewing files that changed from the base of the PR and between f30fe7a and 53d6fae.

📒 Files selected for processing (2)
  • backend/src/utils/api-helpers.test.ts
  • backend/src/utils/api-helpers.ts
✅ Files skipped from review due to trivial changes (2)
  • backend/src/utils/api-helpers.ts
  • backend/src/utils/api-helpers.test.ts

📝 Walkthrough

Walkthrough

三个聊天/响应端点(completions、messages、responses)改为在未明确设置 body.stream 时基于 Accept: text/event-stream 自动开启 SSE;流式实现从返回 async generator 改为构造 ReadableStream 并显式设置 SSE 相关响应头。新增 acceptsEventStream 工具及其单元测试,流内错误改为作为 SSE 事件写回客户端。

Changes

SSE 流式响应改造

Layer / File(s) Summary
协议协商 helper
backend/src/utils/api-helpers.ts
新增 acceptsEventStream(headers: Headers): boolean,解析 Accept 头以识别 text/event-stream(含 q 权重解析)。
行为协商(端点层)
backend/src/api/v1/completions.ts, backend/src/api/v1/messages.ts, backend/src/api/v1/responses.ts
body.stream 未显式设置时,基于 acceptsEventStream(reqHeaders)body.stream 设为 true,使客户端 Accept: text/event-stream 可触发流式输出。
流式交付实现
backend/src/api/v1/completions.ts, backend/src/api/v1/messages.ts, backend/src/api/v1/responses.ts
替换原先通过 async generator 直接 yield 的实现,改为创建 ReadableStream<Uint8Array>、手动编码并 enqueue SSE chunk,并以 new Response(sseStream, { headers: ... }) 返回,显式设置 Content-Type: text/event-stream; charset=utf-8Cache-ControlConnection 等头。
流内错误处理
backend/src/api/v1/completions.ts, backend/src/api/v1/messages.ts, backend/src/api/v1/responses.ts
把流处理错误捕获移入 ReadableStreamstart(),在请求未中止时通过 controller enqueue 一个 SSE 格式的 event: error(或 data: ...)消息发回客户端,避免抛出终止流的未捕获异常。
测试
backend/src/utils/api-helpers.test.ts
新增 Bun 测试覆盖 acceptsEventStream 的匹配、大小写/空白容错、带权重 q 值接受或拒绝及异常 q 解析等用例。

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ServerEndpoint
  participant StreamProcessor
  participant UpstreamModel

  Client->>ServerEndpoint: POST /v1/... (Accept: text/event-stream)
  ServerEndpoint->>ServerEndpoint: calls acceptsEventStream(headers)
  ServerEndpoint-->>Client: if streaming -> Response(ReadableStream) with SSE headers
  ServerEndpoint->>StreamProcessor: start processing (processStreamingResponse)
  StreamProcessor->>UpstreamModel: request generation (streaming)
  UpstreamModel-->>StreamProcessor: yields chunks
  StreamProcessor-->>ServerEndpoint: encodes SSE chunks (enqueue)
  ServerEndpoint-->>Client: SSE chunks over ReadableStream
  UpstreamModel-->>StreamProcessor: final/done
  StreamProcessor-->>Client: enqueue "data: [DONE]" (then close)
  Note over StreamProcessor,ServerEndpoint: on error -> enqueue SSE "event: error" if not aborted
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

"兔子写于键盘旁,轻敲字节庆流光。
Accept 问候被识别,SSE 旗帜在响应上。
流中错误唱首歌,data: error 送到窗。
测试护卫新接口,头部明示不再忘。 🥕"

🚥 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 PR title clearly and concisely summarizes the main change: restoring correct SSE Content-Type header for streaming responses after Elysia version upgrade.
Linked Issues check ✅ Passed Code changes fully implement all requirements from issue #81: correct Content-Type header set to text/event-stream, Accept header negotiation added, native Response with ReadableStream ensures proper streaming, and error handling preserved.
Out of Scope Changes check ✅ Passed All changes directly address issue #81 requirements: three endpoints updated with correct headers, Accept negotiation helper added with tests, and no unrelated modifications found.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/sse-content-type

Review rate limit: 4/5 reviews remaining, refill in 12 minutes.

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

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements content negotiation for Server-Sent Events (SSE) by checking the Accept header when the stream parameter is omitted. It also refactors the streaming logic in the completions, messages, and responses APIs to return a native Response wrapping a ReadableStream, ensuring proper SSE headers and avoiding framework-level double-prefixing. Feedback was provided regarding the acceptsEventStream utility, which currently does not account for the quality factor (q=0) in the Accept header.

Comment thread backend/src/utils/api-helpers.ts
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

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 the current code and only fix it if needed.

Inline comments:
In `@backend/src/utils/api-helpers.ts`:
- Around line 54-61: The acceptsEventStream function incorrectly treats values
like "text/event-stream;q=0" as acceptable and also mis-matches types via
startsWith; update acceptsEventStream(headers: Headers) to parse each Accept
header entry into its media-type and parameters, ignore entries with q=0, and
only accept exact media type "text/event-stream" (ignore structured suffixes
like "+json"); specifically, split by commas, for each part trim and split on
";" to extract the mime-type and params, parse q parameter (default >0), and
return true only when mime-type === "text/event-stream" and q > 0 so negotiation
won’t incorrectly select SSE when body.stream is not set.
🪄 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: Repository UI (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 56e2487e-88d6-4da6-a1f2-cb63ff4fbe1b

📥 Commits

Reviewing files that changed from the base of the PR and between a394871 and cbc169c.

📒 Files selected for processing (4)
  • backend/src/api/v1/completions.ts
  • backend/src/api/v1/messages.ts
  • backend/src/api/v1/responses.ts
  • backend/src/utils/api-helpers.ts

Comment thread backend/src/utils/api-helpers.ts Outdated
Round 1 review (#82): both gemini-code-assist and CodeRabbit flagged the
same issue in `acceptsEventStream`.

1. Quality factor q=0 was not respected.
   `Accept: text/event-stream;q=0` means "do not accept SSE" per
   RFC 7231 §5.3.1, but the prior helper returned true and would
   incorrectly opt the client into streaming. Now parse the q
   parameter and only accept entries with q > 0 (default 1 when
   absent).

2. Structured-suffix media types were silently matched.
   `startsWith("text/event-stream")` would also accept
   `text/event-stream+json` etc. Now match the media type exactly
   after lower-casing and trimming.

Verified with the same inputs both bots flagged:

- text/event-stream;q=0          → false (was true)
- text/event-stream;q=0.0        → false (was true)
- text/event-stream+json         → false (was true)
- text/event-stream;q=0, application/json → false (was true)

Existing positive cases (plain SSE, weighted lists with q>0,
case/whitespace variants) still return true.

Tests: api-helpers.test.ts adds 12 cases covering each branch.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

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 the current code and only fix it if needed.

Inline comments:
In `@backend/src/utils/api-helpers.ts`:
- Around line 67-72: The current q parsing uses params.find((p) =>
p.startsWith("q=")) which misses spaced forms like "q = 0" and thus treats them
as absent; update the logic in the function handling q parsing to trim each
param and match q with optional whitespace using a regex or split: e.g. find a
param where p.trim().toLowerCase().startsWith("q") and then extract the value
with p.trim().match(/^q\s*=\s*(.+)$/i) (or split on '=' after trim), convert to
Number, and return Number.isFinite(q) && q > 0; add a unit test covering
"text/event-stream ; q = 0" and "q=0" and valid positive q values to prevent
regression.
🪄 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: Repository UI (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 65bf70c4-e611-4930-ab38-d23e483372c1

📥 Commits

Reviewing files that changed from the base of the PR and between cbc169c and f30fe7a.

📒 Files selected for processing (2)
  • backend/src/utils/api-helpers.test.ts
  • backend/src/utils/api-helpers.ts

Comment thread backend/src/utils/api-helpers.ts Outdated
Round 2 review (#82): CodeRabbit flagged that the round-1 q parsing
used `param.startsWith("q=")` which only matches the compact form.
Per RFC 7230 §3.2.6, optional whitespace is permitted around the `=`
in `parameter = token "=" ( token / quoted-string )`, so a strict
client may send `Accept: text/event-stream ; q = 0`. The prior fix
treated such forms as "no q parameter present" and returned true,
incorrectly opting the client into streaming.

Replaced startsWith check with an indexOf("=") + slice + trim pass:
parse name and value separately so whitespace around the delimiter
no longer hides q. Empty `q=` value now correctly resolves to false
(Number("") = 0, fails q > 0).

Verified before/after:

- text/event-stream ; q = 0    → was true, now false
- text/event-stream ; q = 0.5  → was true (default-q path), now true (real q parsed)
- text/event-stream;q=0        → still false (round-1 case)
- text/event-stream;q=         → was true (empty slice), now false

Tests: api-helpers.test.ts +3 cases covering whitespace q=0,
whitespace q=0.5, and empty q value. 12 → 15 cases.
@koitococo koitococo merged commit 15300f1 into main May 4, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SSE responses incorrectly labeled as Content-Type: text/plain (should be text/event-stream)

2 participants