Packages
openai_ex
0.9.5
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.0
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.2
0.4.1
0.4.0
0.3.0
0.2.3
0.2.2
0.2.1
0.2.0
0.1.9
0.1.8
0.1.7
0.1.6
0.1.5
0.1.4
0.1.3
0.1.2
0.1.1
0.1.0
Community maintained Elixir library for OpenAI API
Current section
Files
Jump to
Current section
Files
lib/openai_ex/http_sse.ex
defmodule OpenaiEx.HttpSse do
@moduledoc false
alias OpenaiEx.Http.Finch, as: Client
alias OpenaiEx.Error
require Logger
# based on
# https://gist.github.com/zachallaun/88aed2a0cef0aed6d68dcc7c12531649
# and
# https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream
def post(openai = %OpenaiEx{}, url, json: json) do
me = self()
ref = make_ref()
task = Task.async(fn -> finch_stream(openai, url, json, me, ref) end)
result =
with {:ok, status} <- receive_with_timeout(ref, :status, openai.receive_timeout),
{:ok, headers} <- receive_with_timeout(ref, :headers, openai.receive_timeout) do
if status in 200..299 do
stream_receiver = create_stream_receiver(ref, openai.stream_timeout)
body_stream = Stream.resource(&init_stream/0, stream_receiver, end_stream(task))
{:ok, %{status: status, headers: headers, body_stream: body_stream, task_pid: task.pid}}
else
with {:ok, body} <- extract_error(ref, "", openai.receive_timeout) do
response = %{status: status, headers: headers, body: body}
{:error, Error.status_error(status, response, body)}
else
:error -> {:error, Error.sse_timeout_error()}
end
end
else
:error -> {:error, Error.sse_timeout_error()}
end
unless match?({:ok, %{task_pid: _}}, result), do: Task.shutdown(task)
result
end
def cancel_request(task_pid) when is_pid(task_pid) do
send(task_pid, :cancel_request)
end
defp finch_stream(openai = %OpenaiEx{}, url, json, me, ref) do
request = Client.build_post(openai, url, json: json)
send_me_chunk = create_chunk_sender(me, ref)
try do
Client.stream(request, openai, send_me_chunk)
send(me, {:done, ref})
catch
:throw, :cancel_request -> {:exception, :cancel_request}
end
end
defp create_chunk_sender(me, ref) do
fn chunk, _acc ->
receive do
:cancel_request ->
send(me, {:canceled, ref})
throw(:cancel_request)
after
0 -> send(me, {:chunk, chunk, ref})
end
end
end
defp receive_with_timeout(ref, type, timeout) do
receive do
{:chunk, {^type, value}, ^ref} -> {:ok, value}
after
timeout -> :error
end
end
defp init_stream, do: ""
defp create_stream_receiver(ref, timeout) do
fn acc when is_binary(acc) ->
receive do
{:chunk, {:data, evt_data}, ^ref} ->
{events, next_acc} = extract_events(evt_data, acc)
{[events], next_acc}
# some 3rd party providers seem to be ending the stream with eof,
# rather than 2 line terminators. Hopefully those will be fixed and this
# can be removed in the future
{:done, ^ref} when acc == "data: [DONE]" ->
Logger.warning("\"data: [DONE]\" should be followed by 2 line terminators")
{:halt, acc}
{:done, ^ref} ->
{:halt, acc}
{:canceled, ^ref} ->
Logger.info("Request canceled by user")
{:halt, {:exception, :canceled}}
after
timeout ->
Logger.warning("Stream timeout after #{timeout}ms")
{:halt, {:exception, :timeout}}
end
end
end
defp end_stream(task) do
fn acc ->
try do:
(case acc do
{:exception, :timeout} -> raise(Error.sse_timeout_error())
{:exception, :canceled} -> raise(Error.sse_user_cancellation())
_ -> :ok
end),
after: Task.shutdown(task)
end
end
@double_eol ~r/(\r?\n|\r){2}/
@double_eol_eos ~r/(\r?\n|\r){2}$/
defp extract_events(evt_data, acc) do
all_data = acc <> evt_data
if Regex.match?(@double_eol, all_data) do
{remaining, lines} = extract_lines(all_data)
events = process_fields(lines)
{events, remaining}
else
{[], all_data}
end
end
defp extract_lines(data) do
lines = String.split(data, @double_eol)
incomplete_line = !Regex.match?(@double_eol_eos, data)
if incomplete_line, do: lines |> List.pop_at(-1), else: {"", lines}
end
defp process_fields(lines) do
lines
|> Enum.map(&extract_field/1)
|> Enum.filter(&data?/1)
|> Enum.map(&to_json/1)
end
defp to_json(field) do
case field do
%{data: data} ->
%{data: Jason.decode!(data)}
%{eventType: value} ->
[event_id, data] = String.split(value, "\ndata: ", parts: 2)
%{event: event_id, data: Jason.decode!(data)}
end
end
defp data?(field) do
case field do
%{data: "[DONE]"} -> false
%{data: _} -> true
%{eventType: "done\ndata: [DONE]"} -> false
%{eventType: _} -> true
_ -> false
end
end
defp extract_field(line) do
[name | rest] = String.split(line, ":", parts: 2)
value = Enum.join(rest, "") |> String.replace_prefix(" ", "")
case name do
"data" -> %{data: value}
"event" -> %{eventType: value}
"id" -> %{lastEventId: value}
"retry" -> %{retry: value}
_ -> nil
end
end
defp extract_error(ref, acc, timeout) do
receive do
{:chunk, {:data, chunk}, ^ref} -> extract_error(ref, acc <> chunk, timeout)
{:done, ^ref} -> {:ok, Jason.decode!(acc)}
after
timeout -> :error
end
end
end