Packages
nous
0.15.7
0.17.0
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.8
0.15.7
0.15.6
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.17
0.12.16
0.12.15
0.12.14
0.12.13
0.12.12
0.12.11
0.12.9
0.12.7
0.12.6
0.12.5
0.12.3
0.12.2
0.12.0
0.11.3
0.11.0
0.10.1
0.10.0
0.9.0
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.5.0
AI agent framework for Elixir with multi-provider LLM support
Current section
Files
Jump to
Current section
Files
lib/nous/http/stream_backend/req.ex
defmodule Nous.HTTP.StreamBackend.Req do
@moduledoc """
`Nous.HTTP.StreamBackend` implementation backed by `Req` (Finch
underneath).
Default streaming backend. Drives `Req.post/1` with the `:into`
callback so chunks are pushed into a `Task`, which forwards them to
the consuming `Stream.resource` via `send/2`.
## Backpressure
Req's `:into` callback runs in the spawned `Task`. Forwarding to the
consumer process is `send/2`, so a fast producer + slow consumer can
grow the consumer's mailbox unboundedly. This is acceptable for
typical LLM workloads where token-generation rate is the bottleneck
and consumers are parsing-bound (parsing throttles naturally).
Callers whose downstream consumers can block per chunk (LiveView
fan-out under load, persistence-on-every-chunk, slow IO) should use
`Nous.HTTP.StreamBackend.Hackney` instead, which provides strict
pull-based backpressure via `:hackney`'s `{:async, :once}` mode.
## TLS verification
Req's defaults handle TLS verification via Mint/Finch (system CAs
with peer verification). No additional configuration needed.
"""
@behaviour Nous.HTTP.StreamBackend
require Logger
alias Nous.Providers.HTTP
# 3 minutes — LLM streams (especially with reasoning) can sit silent
# between chunks long enough to trip a tighter timeout. Per-call
# `:timeout` opt overrides.
@default_timeout 180_000
@impl Nous.HTTP.StreamBackend
def stream(url, body, headers, opts \\ [])
def stream(url, body, headers, opts)
when is_binary(url) and is_map(body) and is_list(headers) do
timeout = Keyword.get(opts, :timeout, @default_timeout)
stream_parser = Keyword.get(opts, :stream_parser)
finch_name = Keyword.get(opts, :finch_name) || Application.get_env(:nous, :finch, Nous.Finch)
parent = self()
ref = make_ref()
task = start_request_task(url, body, headers, timeout, finch_name, parent, ref)
state = %{
ref: ref,
task: task,
task_ref: task.ref,
buffer: "",
done: false,
timeout: timeout,
stream_parser: stream_parser
}
stream =
Stream.resource(
fn -> state end,
&next_chunk/1,
&cleanup/1
)
{:ok, stream}
end
defp start_request_task(url, body, headers, timeout, finch_name, parent, ref) do
Task.async(fn ->
result =
Req.post(url,
json: body,
headers: headers,
receive_timeout: timeout,
finch: finch_name,
into: fn {:data, chunk}, {req, resp} ->
if resp.status in 200..299 do
send(parent, {ref, {:chunk, chunk}})
{:cont, {req, resp}}
else
# Non-2xx: accumulate body locally so the post-call status
# check has the full error body to report. Do not forward.
{:cont, {req, %{resp | body: (resp.body || "") <> chunk}}}
end
end
)
case result do
{:ok, %Req.Response{status: status}} when status in 200..299 ->
send(parent, {ref, :done})
{:ok, %Req.Response{status: status, body: response_body}} ->
Logger.error("Req stream got error status #{status}")
send(parent, {ref, {:error, %{status: status, body: response_body}}})
{:error, reason} ->
Logger.error("Req stream error: #{inspect(reason)}")
send(parent, {ref, {:error, reason}})
end
end)
end
# Get the next batch of events.
defp next_chunk(%{done: true} = state), do: {:halt, state}
defp next_chunk(state) do
receive do
{ref, :done} when ref == state.ref ->
{events, _} = HTTP.flush_stream_buffer(state.buffer, state.stream_parser)
final_events =
Enum.reject(events, fn
nil -> true
{:parse_error, _} -> true
_ -> false
end)
if Enum.empty?(final_events) do
{:halt, %{state | done: true}}
else
{final_events, %{state | done: true, buffer: ""}}
end
{ref, {:error, reason}} when ref == state.ref ->
{[{:stream_error, reason}], %{state | done: true}}
# Task crashed without sending an explicit completion message —
# surface it as a stream error instead of waiting for the receive
# timeout. The :normal case here can only fire if a stale DOWN
# arrives before our explicit messages, which doesn't happen with
# Task.async monitor ordering, so any DOWN here is abnormal.
{:DOWN, task_ref, :process, _pid, reason} when task_ref == state.task_ref ->
Logger.error("Req stream task died: #{inspect(reason)}")
{[{:stream_error, %{reason: :task_died, details: reason}}], %{state | done: true}}
{ref, {:chunk, chunk}} when ref == state.ref ->
new_buffer = state.buffer <> chunk
if byte_size(new_buffer) > HTTP.max_buffer_size() do
Logger.error("SSE buffer overflow, terminating stream")
{[{:stream_error, %{reason: :buffer_overflow}}], %{state | done: true}}
else
{events, remaining_buffer} =
HTTP.parse_stream_buffer(new_buffer, state.stream_parser)
{valid_events, errors} =
Enum.split_with(events, fn
{:parse_error, _} -> false
_ -> true
end)
for {:parse_error, err} <- errors do
Logger.debug("SSE parse error (ignored): #{inspect(err)}")
end
if Enum.empty?(valid_events) do
next_chunk(%{state | buffer: remaining_buffer})
else
{valid_events, %{state | buffer: remaining_buffer}}
end
end
after
state.timeout ->
Logger.error("Req stream timeout after #{state.timeout}ms")
{[{:stream_error, %{reason: :timeout, timeout_ms: state.timeout}}], %{state | done: true}}
end
end
defp cleanup(%{task: nil}), do: :ok
defp cleanup(%{task: task}) do
# Brutal kill: the task may still be in Req.post pulling chunks. We
# don't care about graceful shutdown — the consumer halted the
# enumerator, which means it's done with the stream.
_ = Task.shutdown(task, :brutal_kill)
:ok
end
end