Skip to content

Commit 34e94b5

Browse files
authored
Merge pull request #2 from martosaur/am-expose-parser
Expose Parser and add OpenAI example in usage guide
2 parents a964b78 + b6395a3 commit 34e94b5

5 files changed

Lines changed: 124 additions & 10 deletions

File tree

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,21 @@ IO.inspect(events)
5454
# [%{event: "message", data: "{\"complete\":true}"}]
5555
```
5656

57+
58+
Alternatively, you can manually parse chunks using `ServerSentEvents.Parse.parse/2`:
59+
60+
```elixir
61+
state = ServerSentEvents.Parser.new()
62+
{events, state} = ServerSentEvents.Parser.parse(state, "event: event\ndata: {\"complete\":")
63+
IO.inspect(events) # []
64+
65+
{events, state} = ServerSentEvents.Parser.parse(state, "true}\n\nevent: event\ndata: {")
66+
IO.inspect(events) # [event: "event", %{data: "{\"complete\":true}"}]
67+
68+
{events, state} = ServerSentEvents.Parser.parse(state, "\"key\":\"value\"}\n\n")
69+
IO.inspect(events) # [event: "event", %{data: "{\"key\":\"value\"}"}]
70+
```
71+
5772
Events are maps that always include `:data`, and may also include `:id`, `:event`, or `:retry`.
5873
The `:id`, `:event`, and `:data` values are binaries. The `:retry` value is a non-negative
5974
integer when present.

guides/usage.livemd

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
```elixir
44
Mix.install([
55
{:req, "~> 0.5.17"},
6-
{:server_sent_events, "1.0.0"}
6+
{:server_sent_events, "1.1.0"}
77
])
88
```
99

@@ -59,3 +59,59 @@ Enum.each(stream, fn event ->
5959
end
6060
end)
6161
```
62+
63+
## Req+OpenAI+SSE Parse
64+
65+
To run this livebook, you must have a valid `OPENAI_API_KEY` environment variable enabled for this livebook.
66+
67+
```elixir
68+
defmodule OpenAI do
69+
def responses(request, callback \\ &Function.identity/1) do
70+
Req.new(
71+
base_url: "https://api.openai.com",
72+
auth: {:bearer, System.fetch_env!("LB_OPENAI_API_KEY")},
73+
method: :post,
74+
url: "/v1/responses",
75+
json: Map.put(request, :stream, true),
76+
into: fn
77+
{:data, data}, {req, %{status: 200} = res} ->
78+
state = Req.Request.get_private(req, :sse_state, ServerSentEvents.Parser.new())
79+
{events, state} = ServerSentEvents.Parser.parse(state, data)
80+
req = Req.Request.put_private(req, :sse_state, state)
81+
82+
res =
83+
Enum.reduce(events, res, fn
84+
%{data: encoded, event: "response.output_text.delta"}, res ->
85+
%{"delta" => delta} = JSON.decode!(encoded)
86+
callback.(delta)
87+
res
88+
89+
%{data: encoded, event: "response.completed"}, res ->
90+
%{"response" => response} = JSON.decode!(encoded)
91+
%{res | body: response}
92+
93+
_, res ->
94+
res
95+
end)
96+
97+
{:cont, {req, res}}
98+
99+
{:data, data}, {req, res} -> {:cont, {req, %{res | body: data}}}
100+
end
101+
)
102+
end
103+
end
104+
```
105+
106+
In this example we use a different Req's streaming style. Instead of setting `into: :self` and decoding a stream of incoming messages, we equip Req with 2-arity callback that is called on each chunk. In the callback, we use `ServerSentEvents.Parser` to parse incoming chunks while maintaining parser state in request struct. For each parsed `response.output_text.delta` event, we pass it to a client-provided callback, which can, for example, send delta to a LiveView process. Finally, once we receive `response.completed` event with a complete response body, we use this body as a final request result. This is useful if the caller wants to access final response body in addition to streamed deltas.
107+
108+
```elixir
109+
%{
110+
model: "gpt-5.4-nano",
111+
reasoning: %{effort: "none"},
112+
instructions: "You are a poet, fluent in multiple languages and emoji usage.",
113+
input: "Write a poem across multiple languages and emojis!"
114+
}
115+
|> OpenAI.responses(&IO.write/1)
116+
|> Req.request!()
117+
```

lib/server_sent_events.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ defmodule ServerSentEvents do
4545
"""
4646
@spec decode_stream(Enumerable.t()) :: Enumerable.t(event())
4747
def decode_stream(stream) do
48-
Stream.transform(stream, %Parser{phase: :start}, fn chunk, state ->
48+
Stream.transform(stream, Parser.new(), fn chunk, state ->
4949
Parser.parse(state, chunk)
5050
end)
5151
end

lib/server_sent_events/parser.ex

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
defmodule ServerSentEvents.Parser do
2-
@moduledoc false
2+
@moduledoc """
3+
Low level SSE parser.
34
4-
@type event :: %{
5-
required(:data) => binary(),
6-
optional(:event) => binary(),
7-
optional(:id) => binary(),
8-
optional(:retry) => non_neg_integer()
9-
}
5+
See [Usage Guide](guides/usage.livemd) for usage example.
6+
"""
107

118
@type state :: %__MODULE__{
129
phase: :start | :field | :key | :value_start | :value | :skip_line | :cr,
@@ -17,10 +14,54 @@ defmodule ServerSentEvents.Parser do
1714

1815
defstruct [:phase, :key, :value, :event]
1916

17+
@doc """
18+
Creates new Parser state that can be then passed to `parse/2`
19+
20+
## Examples
21+
22+
iex> ServerSentEvents.Parser.new()
23+
%ServerSentEvents.Parser{phase: :start, key: nil, value: nil, event: nil}
24+
"""
25+
@spec new() :: state()
26+
def new() do
27+
%__MODULE__{phase: :start}
28+
end
29+
30+
@doc """
31+
Parses binary chunk and returns complete events and an active state
32+
33+
## Examples
34+
35+
iex> ServerSentEvents.Parser.parse("id: 1\\nevent: message\\nretry: 5000\\ndata: hello\\n\\n")
36+
{[%{data: "hello", id: "1", retry: 5000, event: "message"}],
37+
%ServerSentEvents.Parser{phase: :field, key: nil, value: nil, event: nil}}
38+
"""
39+
@spec parse(input :: binary()) :: {[ServerSentEvents.event()], state()}
2040
def parse(input) when is_binary(input) do
21-
parse(%__MODULE__{phase: :start}, input)
41+
new() |> parse(input)
2242
end
2343

44+
@doc """
45+
Parses binary chunk using existing parser state and returns complete events
46+
and an updated state
47+
48+
## Examples
49+
50+
iex> state = ServerSentEvents.Parser.new()
51+
%ServerSentEvents.Parser{phase: :start, key: nil, value: nil, event: nil}
52+
iex> {_, state} = ServerSentEvents.Parser.parse(state, "id: 1\\nevent: message\\n")
53+
{[],
54+
%ServerSentEvents.Parser{
55+
phase: :field,
56+
key: nil,
57+
value: nil,
58+
event: %{id: "1", event: "message"}
59+
}}
60+
iex> {_, _state} = ServerSentEvents.Parser.parse(state, "retry: 5000\\ndata: hello\\n\\n")
61+
{[%{data: "hello", id: "1", retry: 5000, event: "message"}],
62+
%ServerSentEvents.Parser{phase: :field, key: nil, value: nil, event: nil}}
63+
"""
64+
@spec parse(state(), input :: binary()) :: {[ServerSentEvents.event()], state()}
2465
def parse(%__MODULE__{phase: phase, key: key, value: value, event: event}, input)
2566
when is_binary(input) do
2667
parse(input, phase, key, value, event, [])

test/server_sent_events/parser_test.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
defmodule ServerSentEvents.ParserTest do
22
use ExUnit.Case, async: true
33

4+
doctest ServerSentEvents.Parser
5+
46
alias ServerSentEvents.Parser
57

68
test "basic example" do

0 commit comments

Comments
 (0)