Summary
The SSE parsing in routstr/upstream/base.py was significantly improved in commit 31ff1fd (buffered \n\n-delimited events instead of re.split(b"data: ", chunk)). That fix correctly resolved the most serious bug — splitting on the data: prefix per raw chunk, which broke events spanning TCP chunk boundaries.
However, the current parser still makes assumptions that diverge from the SSE spec and from how our own JS SDK (@routstr/sdk, client/sse.ts) parses the same streams. These are latent correctness bugs that surface with any upstream that doesn't emit the exact data: {json}\n\n shape.
Affected code
routstr/upstream/base.py — both streaming generators (the chat/completions path ~line 717 and the second generator ~line 1118). Both share this logic:
sse_buffer = b""
async for chunk in response.aiter_bytes():
sse_buffer += chunk
while b"\n\n" in sse_buffer:
event_raw, sse_buffer = sse_buffer.split(b"\n\n", 1)
event_raw = event_raw.strip()
if not event_raw:
continue
if event_raw == b"data: [DONE]":
done_seen = True
continue
data_prefix = b"data: "
if event_raw.startswith(data_prefix):
payload_bytes = event_raw[len(data_prefix):]
else:
payload_bytes = event_raw
obj = json.loads(payload_bytes)
...
Bugs
1. Only \n\n is recognized as an event delimiter; \r\n\r\n is not
Per spec, an event is terminated by a blank line, where a line ending may be \n, \r, or \r\n. The parser only splits on b"\n\n". An upstream/proxy emitting \r\n\r\n-terminated events (legal) will never satisfy b"\n\n" in sse_buffer — the buffer grows unbounded and no events are yielded until the stream closes (and even then the tail is dropped, see #3).
2. Multi-line data: events are mis-parsed
A single event may contain multiple data: lines that must be concatenated with \n to reconstruct the payload:
data: {"choices":[{"delta":
data: {"content":"hi"}}]}
The code strips only the first data: prefix, then hands the rest (still containing a literal data: on line 2) to json.loads, which raises. The event falls through to the non-JSON pass-through branch, so usage/cost is not extracted and the model/id rewriting is silently skipped for that chunk.
3. The trailing buffer is never flushed at stream end
If the upstream closes without a final \n\n (common for the last event before [DONE], or providers that omit the trailing blank line), whatever remains in sse_buffer after the async for loop is dropped. The final content chunk and/or usage chunk can be lost — there is no post-loop drain.
4. [DONE] detection is over-strict
if event_raw == b"data: [DONE]" requires exactly data: [DONE] after strip. It misses data:[DONE] (no space — also legal) and [DONE] arriving as a separate data: line, so done_seen can stay False and [DONE] may be re-emitted as a non-JSON pass-through event.
Reference implementation (our JS SDK)
@routstr/sdk (client/sse.ts) already handles all of the above:
- Splits on
\r?\n\r?\n (handles \n\n, \r\n\r\n, \r\r).
- Splits each event block on
\r?\n and concatenates all data: lines with \n before parsing.
- Flushes the trailing buffer on stream end.
- Treats the post-prefix payload
=== "[DONE]" rather than matching the full raw line, and tolerates both data: and data:.
const terminator = /\r?\n\r?\n/g; // event delimiter
const lines = eventBlock.split(/\r?\n/);
const dataParts: string[] = [];
for (const line of lines) {
if (!line || line.startsWith(":")) continue; // skip comments
if (line.startsWith("data:")) {
const value = line.startsWith("data: ") ? line.slice(6) : line.slice(5);
dataParts.push(value);
}
}
const payload = dataParts.join("\n"); // multi-line data
Suggested fix
For both streaming generators in routstr/upstream/base.py:
- Split the buffer on a blank-line delimiter that accepts
\r\n\r\n as well as \n\n (e.g. normalize \r\n → \n, or split on rb"\r?\n\r?\n"), still buffering the incomplete tail.
- Within an event, split on line endings, collect all
data: lines (strip data: or data:), and join with \n before json.loads.
- After the
async for loop ends, drain any non-empty sse_buffer as a final event.
- Compare the reconstructed payload to
[DONE] after prefix stripping, not the raw data: [DONE] bytes.
Severity / scope
For OpenAI-compatible upstreams emitting exactly data: {json}\n\n with single-line data: (the common case today), the current code works. These bugs bite when an upstream uses \r\n\r\n (#1 — total stall), sends multi-line data: (#2 — lost usage/cost, skipped model/id rewrite), or closes without a trailing blank line (#3 — dropped final chunk).
Acceptance criteria
Summary
The SSE parsing in
routstr/upstream/base.pywas significantly improved in commit31ff1fd(buffered\n\n-delimited events instead ofre.split(b"data: ", chunk)). That fix correctly resolved the most serious bug — splitting on thedata:prefix per raw chunk, which broke events spanning TCP chunk boundaries.However, the current parser still makes assumptions that diverge from the SSE spec and from how our own JS SDK (
@routstr/sdk,client/sse.ts) parses the same streams. These are latent correctness bugs that surface with any upstream that doesn't emit the exactdata: {json}\n\nshape.Affected code
routstr/upstream/base.py— both streaming generators (the chat/completions path ~line 717 and the second generator ~line 1118). Both share this logic:Bugs
1. Only
\n\nis recognized as an event delimiter;\r\n\r\nis notPer spec, an event is terminated by a blank line, where a line ending may be
\n,\r, or\r\n. The parser only splits onb"\n\n". An upstream/proxy emitting\r\n\r\n-terminated events (legal) will never satisfyb"\n\n" in sse_buffer— the buffer grows unbounded and no events are yielded until the stream closes (and even then the tail is dropped, see #3).2. Multi-line
data:events are mis-parsedA single event may contain multiple
data:lines that must be concatenated with\nto reconstruct the payload:The code strips only the first
data:prefix, then hands the rest (still containing a literaldata:on line 2) tojson.loads, which raises. The event falls through to the non-JSON pass-through branch, so usage/cost is not extracted and themodel/idrewriting is silently skipped for that chunk.3. The trailing buffer is never flushed at stream end
If the upstream closes without a final
\n\n(common for the last event before[DONE], or providers that omit the trailing blank line), whatever remains insse_bufferafter theasync forloop is dropped. The final content chunk and/or usage chunk can be lost — there is no post-loop drain.4.
[DONE]detection is over-strictif event_raw == b"data: [DONE]"requires exactlydata: [DONE]after strip. It missesdata:[DONE](no space — also legal) and[DONE]arriving as a separatedata:line, sodone_seencan stayFalseand[DONE]may be re-emitted as a non-JSON pass-through event.Reference implementation (our JS SDK)
@routstr/sdk(client/sse.ts) already handles all of the above:\r?\n\r?\n(handles\n\n,\r\n\r\n,\r\r).\r?\nand concatenates alldata:lines with\nbefore parsing.=== "[DONE]"rather than matching the full raw line, and tolerates bothdata:anddata:.Suggested fix
For both streaming generators in
routstr/upstream/base.py:\r\n\r\nas well as\n\n(e.g. normalize\r\n→\n, or split onrb"\r?\n\r?\n"), still buffering the incomplete tail.data:lines (stripdata:ordata:), and join with\nbeforejson.loads.async forloop ends, drain any non-emptysse_bufferas a final event.[DONE]after prefix stripping, not the rawdata: [DONE]bytes.Severity / scope
For OpenAI-compatible upstreams emitting exactly
data: {json}\n\nwith single-linedata:(the common case today), the current code works. These bugs bite when an upstream uses\r\n\r\n(#1 — total stall), sends multi-linedata:(#2 — lost usage/cost, skipped model/id rewrite), or closes without a trailing blank line (#3 — dropped final chunk).Acceptance criteria
\r\n\r\nare parsed and yielded.data:events are reconstructed and parsed as one JSON payload.[DONE]is detected fordata: [DONE],data:[DONE], and as a trailingdata:line.client/ssetests cover equivalents and can be ported).