defmodule Nous.Providers.HTTP do @moduledoc """ Shared HTTP utilities for all LLM providers. Two HTTP families, both pluggable: - **Non-streaming** requests (one-shot model calls, web fetching, search APIs) go through a `Nous.HTTP.Backend`. Default is `Nous.HTTP.Backend.Req`; `Nous.HTTP.Backend.Hackney` is also shipped. - **Streaming** requests (SSE / chunked LLM responses) go through a `Nous.HTTP.StreamBackend`. Default is `Nous.HTTP.StreamBackend.Req` (Req's `:into` callback driven by Finch); `Nous.HTTP.StreamBackend.Hackney` provides strict pull-based backpressure via `:hackney`'s `{:async, :once}` mode for callers whose downstream consumers can block per chunk. Both backend layers resolve via the same precedence: per-call opt → env var → app config → default. See `Nous.HTTP.Backend` and `Nous.HTTP.StreamBackend` for selection details. ## Usage # Non-streaming request {:ok, body} = HTTP.post(url, body, headers) # Streaming request — returns a lazy stream of parsed events {:ok, stream} = HTTP.stream(url, body, headers) Enum.each(stream, &process_event/1) # Per-call backend override {:ok, stream} = HTTP.stream(url, body, headers, stream_backend: Nous.HTTP.StreamBackend.Hackney) ## SSE Parsing SSE events follow the Server-Sent Events spec (https://html.spec.whatwg.org/multipage/server-sent-events.html): - Events are separated by double newlines (`\\n\\n`) - Each event contains field lines like `data: {...}` - Multiple `data:` fields are concatenated with newlines - `[DONE]` signals stream completion (OpenAI convention) The default SSE parser (`parse_sse_buffer/1`) is transport-agnostic and shared by both stream backends. Custom parsers can be plugged in via the `:stream_parser` opt; see `Nous.Providers.HTTP.JSONArrayParser` for an example. ## Stream backpressure - `Nous.HTTP.StreamBackend.Req` (default): the `:into` callback runs in a `Task` and feeds the consumer process via `send/2`. BEAM mailboxes are unbounded, so a fast producer + slow consumer can grow the consumer's mailbox. Acceptable for typical LLM workloads where the consumer is parsing-bound (and parsing throttles naturally) or where token-generation rate is the bottleneck. - `Nous.HTTP.StreamBackend.Hackney`: strict pull-based — the consumer calls `:hackney.stream_next/1` per chunk, so the producer literally cannot outrun the consumer. Pick this when downstream consumers can block per chunk (LiveView fan-out, persistence-on-every-chunk, slow IO). """ require Logger # 10MB max buffer @max_buffer_size 10 * 1024 * 1024 # ============================================================================ # Public API # ============================================================================ @doc """ Make a non-streaming POST request. Dispatches to the configured `Nous.HTTP.Backend`. Resolution order (highest precedence first): 1. Per-call `:backend` opt — `HTTP.post(url, body, headers, backend: Nous.HTTP.Backend.Hackney)` 2. `NOUS_HTTP_BACKEND` env var — `req`, `hackney`, or a fully-qualified module name (e.g. `MyApp.MyHTTPBackend`) 3. `Application.get_env(:nous, :http_backend, ...)` 4. Default: `Nous.HTTP.Backend.Req` Returns `{:ok, body}` or `{:error, reason}`. ## Options * `:backend` - Backend module (overrides env / config / default) * `:timeout` - Request timeout in ms (default: 180_000) ## Error Reasons * `%{status: integer(), body: term()}` - HTTP error response * `%Mint.TransportError{}` - Network error (Req backend) * `%JSON.DecodeError{}` - JSON decode error """ @spec post(String.t(), map(), list(), keyword()) :: {:ok, map()} | {:error, term()} def post(url, body, headers, opts \\ []) def post(url, body, headers, opts) when is_binary(url) and is_map(body) and is_list(headers) do backend = Keyword.get(opts, :backend) || configured_backend() backend.post(url, body, headers, opts) end def post(url, body, headers, _opts) do {:error, %ArgumentError{ message: "Invalid arguments: url must be string, body must be map, headers must be list. " <> "Got: url=#{inspect(url)}, body=#{inspect(body)}, headers=#{inspect(headers)}" }} end # Resolve the configured HTTP backend. The env var takes precedence over # app config so ops can A/B-test backends without a redeploy. # # Custom backend modules are resolved via `String.to_existing_atom/1` to # uphold the project-wide rule (review C-2): never `String.to_atom/1` on # untrusted input. If the atom doesn't exist or doesn't implement the # behaviour, fall back to app config / default rather than crash. defp configured_backend do case System.get_env("NOUS_HTTP_BACKEND") do nil -> app_or_default() "req" -> Nous.HTTP.Backend.Req "hackney" -> Nous.HTTP.Backend.Hackney other -> resolve_custom_backend(other, :post, 4, &app_or_default/0) end end defp app_or_default do Application.get_env(:nous, :http_backend, Nous.HTTP.Backend.Req) end defp resolve_custom_backend(name, fun, arity, fallback) do mod = String.to_existing_atom("Elixir." <> name) Code.ensure_loaded?(mod) if function_exported?(mod, fun, arity) do mod else fallback.() end rescue ArgumentError -> fallback.() end @doc """ Make a streaming POST request. Dispatches to the configured `Nous.HTTP.StreamBackend`. Resolution order (highest precedence first): 1. Per-call `:stream_backend` opt 2. `NOUS_HTTP_STREAM_BACKEND` env var — `req`, `hackney`, or a fully-qualified module name 3. `Application.get_env(:nous, :http_stream_backend, ...)` 4. Default: `Nous.HTTP.StreamBackend.Req` Returns `{:ok, stream}` where stream is an `Enumerable.t()` of parsed events. Events are maps with string keys (parsed JSON), `{:stream_done, reason}` tuples on completion, or `{:stream_error, reason}` tuples on failure. ## Options * `:stream_backend` - Backend module (overrides env / config / default) * `:timeout` - Receive timeout in ms (default: 180_000) * `:connect_timeout` - TCP connect timeout in ms (default: 30_000) * `:stream_parser` - Module for parsing the stream buffer (default: SSE). Must implement `parse_buffer/1` returning `{events, remaining_buffer}`. See `Nous.Providers.HTTP.JSONArrayParser` for an example. * `:pool` - (Hackney backend only) Hackney pool name (default: `:default`). ## Error Handling The stream emits `{:stream_error, reason}` on errors and then halts. """ @spec stream(String.t(), map(), list(), keyword()) :: {:ok, Enumerable.t()} | {:error, term()} 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 backend = Keyword.get(opts, :stream_backend) || configured_stream_backend() backend.stream(url, body, ensure_streaming_headers(headers), opts) end def stream(url, body, headers, _opts) do {:error, %ArgumentError{ message: "Invalid arguments: url must be string, body must be map, headers must be list. " <> "Got: url=#{inspect(url)}, body=#{inspect(body)}, headers=#{inspect(headers)}" }} end defp configured_stream_backend do case System.get_env("NOUS_HTTP_STREAM_BACKEND") do nil -> stream_app_or_default() "req" -> Nous.HTTP.StreamBackend.Req "hackney" -> Nous.HTTP.StreamBackend.Hackney other -> resolve_custom_backend(other, :stream, 4, &stream_app_or_default/0) end end defp stream_app_or_default do Application.get_env(:nous, :http_stream_backend, Nous.HTTP.StreamBackend.Req) end # ============================================================================ # SSE Parsing (Public for testing and reuse by stream backends) # ============================================================================ @doc """ Parse an SSE buffer into events. Returns `{events, remaining_buffer}` where events is a list of parsed JSON maps, `{:stream_done, reason}` tuples, or `{:parse_error, reason}` tuples. Handles edge cases: - Empty events (ignored) - Whitespace-only events (ignored) - Malformed JSON (emits `{:parse_error, reason}`) - Multiple data fields per event (concatenated per spec) - Comment lines (ignored) - Buffer overflow protection ## Examples iex> parse_sse_buffer("data: {\\"text\\": \\"hi\\"}\\n\\n") {[%{"text" => "hi"}], ""} iex> parse_sse_buffer("data: partial") {[], "data: partial"} iex> parse_sse_buffer("data: [DONE]\\n\\n") {[{:stream_done, "stop"}], ""} """ @spec parse_sse_buffer(String.t() | nil | any()) :: {list(), String.t()} | {:error, :buffer_overflow} def parse_sse_buffer(buffer) when is_binary(buffer) do # Buffer overflow is now a HARD error, not a silent truncation. The # previous behavior sliced from the front, which cut mid-event/mid-JSON # and produced one parse_error followed by valid events - silent data # loss. Halting here lets the consumer surface the failure cleanly. if byte_size(buffer) > @max_buffer_size do Logger.error("SSE buffer exceeded max size (#{@max_buffer_size} bytes), aborting stream") {:error, :buffer_overflow} else do_parse_sse_buffer(buffer) end end def parse_sse_buffer(nil), do: {[], ""} def parse_sse_buffer(_), do: {[], ""} defp do_parse_sse_buffer(buffer) when is_binary(buffer) do # Split on double newlines (SSE event separator) # Handle both \n\n and \r\n\r\n parts = String.split(buffer, ~r/\r?\n\r?\n/) case parts do [incomplete] -> # No complete events yet {[], incomplete} parts -> # All but the last part are complete events {complete, [incomplete]} = Enum.split(parts, -1) events = complete |> Enum.map(&parse_sse_event/1) |> Enum.reject(&is_nil/1) {events, incomplete} end end @doc """ Parse a single SSE event. Returns parsed JSON map, `{:stream_done, reason}`, `{:parse_error, reason}`, or nil. Handles per SSE spec: - `data:` fields (with or without space after colon) - Multiple `data:` fields concatenated with newlines - `:` prefix for comments (ignored) - `event:`, `id:`, `retry:` fields (ignored for now) - Empty lines within events ## Examples iex> parse_sse_event("data: {\\"key\\": \\"value\\"}") %{"key" => "value"} iex> parse_sse_event("data: [DONE]") {:stream_done, "stop"} iex> parse_sse_event(": this is a comment") nil iex> parse_sse_event("") nil """ @spec parse_sse_event(String.t()) :: map() | {:stream_done, String.t()} | {:parse_error, term()} | nil def parse_sse_event(event) when is_binary(event) do # Trim and check for empty event = String.trim(event) if event == "" do nil else parse_sse_event_lines(String.split(event, ~r/\r?\n/)) end end def parse_sse_event(_), do: nil @doc false # Public for stream-backend reuse only — not part of the public API # surface. Translates the new `{:error, :buffer_overflow}` tuple from # `parse_sse_buffer/1` into the legacy `{events, buffer}` shape so # backends can stay agnostic about the failure mode. @spec parse_stream_buffer(String.t(), module() | nil) :: {list(), String.t()} def parse_stream_buffer(buffer, nil) do case parse_sse_buffer(buffer) do {:error, :buffer_overflow} -> {[{:stream_error, %{reason: :buffer_overflow}}], ""} result -> result end end def parse_stream_buffer(buffer, parser_mod), do: parser_mod.parse_buffer(buffer) @doc false # Public for stream-backend reuse only. Flush remaining buffer at end # of stream — SSE needs a trailing `\n\n` to force the last event # through; custom parsers just re-parse the remaining buffer as-is. # # The chunk handler already enforces `@max_buffer_size` on every # received chunk, so the buffer reaching here is by construction # within limits. The synthetic `"\n\n"` is bookkeeping, not received # data — bypass the public size check so a buffer at exactly the cap # doesn't trip a false-positive overflow on the 2-byte append. Only # surface overflow if the input itself is over. @spec flush_stream_buffer(String.t(), module() | nil) :: {list(), String.t()} def flush_stream_buffer(buffer, nil) do if byte_size(buffer) > @max_buffer_size do {[{:stream_error, %{reason: :buffer_overflow}}], ""} else do_parse_sse_buffer(buffer <> "\n\n") end end def flush_stream_buffer(buffer, parser_mod), do: parser_mod.parse_buffer(buffer) @doc false # Max buffer size — public for stream-backend reuse. def max_buffer_size, do: @max_buffer_size # ============================================================================ # Header Helpers (Public for testing) # ============================================================================ @doc """ Build authorization header for Bearer token auth (OpenAI style). Returns empty list for nil, empty string, or "not-needed" values. """ @spec bearer_auth_header(String.t() | nil) :: list() def bearer_auth_header(nil), do: [] def bearer_auth_header(""), do: [] def bearer_auth_header("not-needed"), do: [] def bearer_auth_header(api_key) when is_binary(api_key), do: [{"authorization", "Bearer #{api_key}"}] def bearer_auth_header(_), do: [] @doc """ Build authorization header for API key auth (Anthropic style). Returns empty list for nil or empty string values. """ @spec api_key_header(String.t() | nil, String.t()) :: list() def api_key_header(nil, _header_name), do: [] def api_key_header("", _header_name), do: [] def api_key_header(api_key, header_name) when is_binary(api_key) and is_binary(header_name) do [{header_name, api_key}] end def api_key_header(_, _), do: [] @doc false # Public for stream-backend reuse. Ensures the request carries # `content-type: application/json` and `accept: text/event-stream` # if the caller didn't supply them. def ensure_streaming_headers(headers) do headers |> maybe_add_header("content-type", "application/json") |> maybe_add_header("accept", "text/event-stream") end defp maybe_add_header(headers, key, value) do key_lower = String.downcase(key) if Enum.any?(headers, fn {k, _} -> String.downcase(to_string(k)) == key_lower end) do headers else [{key, value} | headers] end end # ============================================================================ # Private Functions # ============================================================================ # Parse SSE event from lines defp parse_sse_event_lines(lines) do # Collect all data fields data_parts = lines |> Enum.reduce([], fn line, acc -> cond do # Comment line (starts with :) String.starts_with?(line, ":") -> acc # Data field with space String.starts_with?(line, "data: ") -> [String.replace_prefix(line, "data: ", "") | acc] # Data field without space (valid per spec) String.starts_with?(line, "data:") -> [String.replace_prefix(line, "data:", "") | acc] # Other fields (event:, id:, retry:) - ignore for now String.contains?(line, ":") -> acc # Empty line or continuation true -> acc end end) |> Enum.reverse() if Enum.empty?(data_parts) do nil else # Per SSE spec, multiple data fields are joined with newlines data = Enum.join(data_parts, "\n") parse_data_content(data) end end # Parse the data content (JSON or special markers) defp parse_data_content("[DONE]"), do: {:stream_done, "stop"} defp parse_data_content(""), do: nil defp parse_data_content(data) do case JSON.decode(data) do {:ok, parsed} -> parsed {:error, error} -> # Only log at debug level - malformed data is common during streaming Logger.debug( "Failed to parse SSE data as JSON: #{truncate_for_log(data)}, error: #{inspect(error)}" ) {:parse_error, %{data: data, error: error}} end end # Truncate data for logging to avoid huge log messages defp truncate_for_log(data) when is_binary(data) do if byte_size(data) > 500 do String.slice(data, 0, 500) <> "... (truncated)" else data end end end