Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better SSE parsing #332

Closed
wants to merge 2 commits into from
Closed

Conversation

atesgoral
Copy link
Contributor

@atesgoral atesgoral commented Sep 27, 2023

The issue mentioned in #251 persists.

OpenAI reliably returns one completion JSON per SSE message. However, when intermediaries are placed between OpenAI and the client (e.g. a proxy), the combining and splitting of chunks during streaming/buffering (or even TCP fragmentation) can cause the client to receive chunks with completion fragments (invalid JSON).

This PR brings more spec-compliant SSE parsing. Instead of stripping and ignoring whitespace, processes a chunk by splitting it into blocks delimited by "\n\n", and buffering any remaining block that is not a full data field.

More spec-compliant, but not really fully spec-compliant: There's still no handling of multi-line data spread across adjacent data fields. But there's no need to make this a generic SSE parser since the only concern is hitting OpenAI.

I've updated the http tests by making them emit proper SSE chunks into the stream. I added a test for the fragmented completion JSON case.

Note that this is still a bit of a quick hack and not the best way to parse SSE. An ideal parser would process the stream line by line with a state machine (instead of YOLO splitting at \n\n).

I also noticed two issues that I did not address in the scope of this PR:

  1. I don't think there's such thing as an error field in SSE. It's neither in the spec, nor in OpenAI's documentation. It would be nice to remove the handling of that, or mention in a code comment where it's coming from.
  2. The code simply ignores JSON parse errors. IMHO, the library instead should be unforgiving and error out when an unexpected payload is received. The ignoring of parse errors is a convenience for not directly handling the "[DONE]" value omitted by OpenAI, and also the very JSON fragmentation issue that is not very common, but still a possibility in certain settings.

@@ -53,13 +57,13 @@ def to_json(string)
# @param user_proc [Proc] The inner proc to call for each JSON object in the chunk.
# @return [Proc] An outer proc that iterates over a raw stream, converting it to JSON.
def to_json_stream(user_proc:)
Copy link
Contributor Author

@atesgoral atesgoral Sep 27, 2023

Choose a reason for hiding this comment

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

A better name for this would be to_completion_stream because once the completion JSON is parsed, it's no longer "JSON" -- it's a completion object.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I didn't add a separate unit test for this new module because it's sufficiently tested by the http tests.

stream.call(<<~CHUNK)
data: { "foo": "bar" }

#
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This ensures the trailing \n\n are preserved.

@@ -117,13 +121,14 @@
expect(user_proc).to receive(:call).with(JSON.parse('{"foo": "bar"}'))
expect(user_proc).to receive(:call).with(JSON.parse('{"baz": "qud"}'))

stream.call(<<-CHUNK)
stream.call(<<~CHUNK)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Should be unindenting these, so switched from - to ~ in these heredocs.

Comment on lines -145 to -146
data: { "foo": "bar" }
data: { BAD ]:-> JSON }
Copy link
Contributor Author

@atesgoral atesgoral Sep 27, 2023

Choose a reason for hiding this comment

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

So, this was actually a totally new case that doesn't have to be handled in the scope of OpenAI responses because they don't seem to return anything like this. Normally, adjacent data field values should be concatenated with a \n -- it's a multi-line value. In this case, the above would be read as "{ "foo": "bar" }\n{ BAD ]:-> JSON }" and that is not what this test's intention is.

stream.call(<<~CHUNK)
data: { "foo": "bar" }

error: { "message": "A bad thing has happened!" }
Copy link
Contributor Author

Choose a reason for hiding this comment

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

AFAIK, there's no such thing as an "error" field. But that is beyond the scope of this PR.

end
end

context "when called with JSON split across chunks" do
Copy link
Contributor Author

@atesgoral atesgoral Sep 27, 2023

Choose a reason for hiding this comment

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

This is the not-so-common case this PR intends to fix. Most people using this library to hit OpenAI directly will not encounter this. But I have seen it happen in the past, even when talking to OpenAI directly, when the network was congested, or there was some backpressure in my streaming. I can't put my finger on it right now.

@atesgoral atesgoral changed the title Spec-compliant SSE parsing Better SSE parsing Sep 27, 2023
@chitalian
Copy link

This is awesome! Thanks for working on this @atesgoral

@atesgoral
Copy link
Contributor Author

Related issue: Helicone/helicone/issues/678
Example of some optimistic client not being able to handle fragmentation. And Helicone's workaround for working around those clients by doing some internal buffering: https://github.com/Helicone/helicone/blob/1e3bb5d2c2cac592f3ad2a7812bede86e24699bb/worker/src/lib/HeliconeProxyRequest/handler.ts#L29-L51

@burke burke mentioned this pull request Oct 4, 2023
3 tasks
@atesgoral
Copy link
Contributor Author

I'm working on a proper parser. I'll report back.

@atesgoral atesgoral closed this Oct 13, 2023
@atesgoral atesgoral deleted the robust-sse-parsing branch October 16, 2023 14:03
@atesgoral atesgoral mentioned this pull request Oct 16, 2023
3 tasks
@atesgoral
Copy link
Contributor Author

Published event_stream_parser and created #338 to use that.

@atesgoral atesgoral restored the robust-sse-parsing branch October 16, 2023 16:27
@atesgoral atesgoral deleted the robust-sse-parsing branch November 1, 2023 16:19
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.

2 participants