Packages

AI agent framework for Elixir built on OTP. TEA-based agents with crash isolation, inter-agent messaging, team supervision, and real SSE streaming to Anthropic, OpenAI, Ollama, and more.

Current section

Files

Jump to
raxol_agent lib raxol agent backend http.ex
Raw

lib/raxol/agent/backend/http.ex

defmodule Raxol.Agent.Backend.HTTP do
@moduledoc """
HTTP-based AI backend using Req.
Supports Claude (Anthropic), GPT (OpenAI-compatible), Ollama, and Kimi APIs.
The provider is auto-detected from the base URL or can be set explicitly.
## Configuration
opts = [
api_key: "sk-...",
base_url: "https://api.anthropic.com",
model: "claude-sonnet-4-20250514",
provider: :anthropic, # or :openai, :ollama, :kimi (auto-detected if omitted)
timeout: 30_000
]
## Req Plugins
Pass `:req_plugins` to attach Req response steps (e.g., auto-pay for HTTP 402):
opts = [
api_key: "sk-...",
req_plugins: [
fn req -> Raxol.Payments.Req.AutoPay.attach(req, wallet: MyWallet) end
]
]
Each plugin is a function `(Req.Request.t() -> Req.Request.t())` applied
before the request is sent. This keeps Backend.HTTP agnostic to payment
details while letting callers wire in transparent 402 handling.
"""
@behaviour Raxol.Agent.AIBackend
@default_timeout Raxol.Core.Defaults.health_check_interval_ms()
@default_max_tokens 1_024
@anthropic_api_version "2023-06-01"
@default_ollama_port "11434"
@impl true
def complete(messages, opts \\ []) do
provider = detect_provider(opts)
timeout = Keyword.get(opts, :timeout, @default_timeout)
plugins = Keyword.get(opts, :req_plugins, [])
{url, headers, body} = build_request(provider, messages, opts)
case do_request(url, headers, body, timeout, plugins) do
{:ok, response_body} ->
{:ok, parse_response(provider, response_body)}
{:error, _} = error ->
error
end
end
@impl true
def available? do
Code.ensure_loaded?(Req)
end
@impl true
def name, do: "HTTP Backend"
@impl true
def capabilities, do: [:completion, :streaming, :tool_use]
@impl true
def stream(messages, opts \\ []) do
if available?() do
provider = detect_provider(opts)
timeout = Keyword.get(opts, :timeout, @default_timeout)
plugins = Keyword.get(opts, :req_plugins, [])
{url, headers, body} = build_request(provider, messages, opts)
body = Map.put(body, :stream, true)
caller = self()
ref = make_ref()
task_pid =
spawn_link(fn ->
stream_request(url, headers, body, timeout, caller, ref, plugins)
end)
stream =
Stream.resource(
fn ->
%{
ref: ref,
task_pid: task_pid,
buffer: "",
provider: provider,
content: "",
usage: %{}
}
end,
&stream_next/1,
fn %{task_pid: pid} ->
if Process.alive?(pid), do: Process.exit(pid, :normal)
end
)
{:ok, stream}
else
{:error, :req_not_available}
end
end
defp stream_request(url, headers, body, timeout, caller, ref, plugins) do
try do
req =
Req.new(
url: url,
json: body,
headers: headers,
receive_timeout: timeout,
into: fn {:data, data}, {req, resp} ->
send(caller, {:sse_data, ref, data})
{:cont, {req, resp}}
end
)
|> apply_plugins(plugins)
case Req.post(req) do
{:ok, %{status: status}} when status in 200..299 ->
:ok
{:ok, %{status: status}} ->
send(caller, {:sse_error, ref, "HTTP #{status}"})
{:error, reason} ->
send(caller, {:sse_error, ref, inspect(reason)})
end
rescue
e -> send(caller, {:sse_error, ref, Exception.message(e)})
end
send(caller, {:sse_done, ref})
end
defp stream_next(%{buffer: :halt} = state), do: {:halt, state}
defp stream_next(%{ref: ref, buffer: buffer, provider: provider} = state) do
receive do
{:sse_data, ^ref, data} ->
{events, new_buffer} = parse_sse(buffer <> data, provider)
chunks = for {:text_delta, text} <- events, do: {:chunk, text}
new_content =
state.content <> Enum.map_join(chunks, "", fn {:chunk, t} -> t end)
new_usage =
case Enum.find(events, &match?({:usage, _}, &1)) do
{:usage, u} -> u
nil -> state.usage
end
{chunks, %{state | buffer: new_buffer, content: new_content, usage: new_usage}}
{:sse_error, ^ref, error} ->
{[{:error, error}], %{state | buffer: :halt}}
{:sse_done, ^ref} ->
done =
{:done,
%{
content: state.content,
usage: state.usage,
metadata: %{
backend: :http,
provider: state.provider,
streamed: true
}
}}
{[done], %{state | buffer: :halt}}
after
60_000 ->
{:halt, state}
end
end
# -- SSE parsing -------------------------------------------------------------
defp parse_sse(raw, :ollama) do
lines = String.split(raw, "\n")
{complete, [buffer]} = Enum.split(lines, -1)
events =
complete
|> Enum.reject(&(&1 == ""))
|> Enum.flat_map(fn line ->
case Jason.decode(line) do
{:ok, %{"done" => true}} -> [{:usage, %{}}]
{:ok, %{"message" => %{"content" => text}}} -> [{:text_delta, text}]
_ -> []
end
end)
{events, buffer}
end
defp parse_sse(raw, provider) when provider in [:anthropic, :openai, :kimi] do
parts = String.split(raw, "\n\n")
case parts do
[single] ->
{[], single}
multiple ->
{complete, [buffer]} = Enum.split(multiple, -1)
events =
complete
|> Enum.reject(&(&1 == ""))
|> Enum.flat_map(&parse_sse_event(&1, provider))
{events, buffer}
end
end
defp parse_sse_event(event_text, :anthropic) do
data_line =
event_text
|> String.split("\n")
|> Enum.find(&String.starts_with?(&1, "data: "))
with "data: " <> json <- data_line,
{:ok, parsed} <- Jason.decode(json) do
case parsed do
%{"type" => "content_block_delta", "delta" => %{"text" => text}} ->
[{:text_delta, text}]
%{"type" => "message_delta", "usage" => usage} ->
[{:usage, usage}]
_ ->
[]
end
else
_ -> []
end
end
defp parse_sse_event(event_text, :kimi),
do: parse_sse_event(event_text, :openai)
defp parse_sse_event(event_text, :openai) do
data_line =
event_text
|> String.split("\n")
|> Enum.find(&String.starts_with?(&1, "data: "))
case data_line do
"data: " <> data ->
if data == "[DONE]" do
[{:usage, %{}}]
else
case Jason.decode(data) do
{:ok, %{"choices" => [%{"delta" => %{"content" => text}} | _]}}
when is_binary(text) ->
[{:text_delta, text}]
_ ->
[]
end
end
_ ->
[]
end
end
# -- Request building -------------------------------------------------------
defp build_request(:anthropic, messages, opts) do
base_url = Keyword.get(opts, :base_url, "https://api.anthropic.com")
api_key = Keyword.fetch!(opts, :api_key)
model = Keyword.get(opts, :model, "claude-sonnet-4-20250514")
{system_msgs, chat_msgs} = split_system_messages(messages)
system_text = Enum.map_join(system_msgs, "\n", & &1.content)
url = "#{base_url}/v1/messages"
headers = [
{"x-api-key", api_key},
{"anthropic-version", @anthropic_api_version},
{"content-type", "application/json"}
]
body = %{
model: model,
max_tokens: Keyword.get(opts, :max_tokens, @default_max_tokens),
messages: Enum.map(chat_msgs, &format_message/1)
}
body =
if system_text != "", do: Map.put(body, :system, system_text), else: body
body = maybe_add_tools(:anthropic, body, opts)
{url, headers, body}
end
defp build_request(:openai, messages, opts) do
base_url = Keyword.get(opts, :base_url, "https://api.openai.com")
api_key = Keyword.fetch!(opts, :api_key)
model = Keyword.get(opts, :model, "gpt-4o")
url = "#{base_url}/v1/chat/completions"
headers = [
{"authorization", "Bearer #{api_key}"},
{"content-type", "application/json"}
]
body = %{
model: model,
messages: Enum.map(messages, &format_message/1),
max_tokens: Keyword.get(opts, :max_tokens, @default_max_tokens)
}
body = maybe_add_tools(:openai, body, opts)
{url, headers, body}
end
defp build_request(:kimi, messages, opts) do
base_url = Keyword.get(opts, :base_url, "https://api.moonshot.ai")
api_key = Keyword.fetch!(opts, :api_key)
model = Keyword.get(opts, :model, "kimi-k2.5")
url = "#{base_url}/v1/chat/completions"
headers = [
{"authorization", "Bearer #{api_key}"},
{"content-type", "application/json"}
]
body = %{
model: model,
messages: Enum.map(messages, &format_message/1),
max_tokens: Keyword.get(opts, :max_tokens, @default_max_tokens)
}
{url, headers, body}
end
defp build_request(:ollama, messages, opts) do
base_url = Keyword.get(opts, :base_url, "http://localhost:11434")
model = Keyword.get(opts, :model, "llama3")
url = "#{base_url}/api/chat"
headers = [{"content-type", "application/json"}]
body = %{
model: model,
messages: Enum.map(messages, &format_message/1),
stream: false
}
{url, headers, body}
end
# -- Tool support -----------------------------------------------------------
defp maybe_add_tools(_provider, body, opts) do
case Keyword.get(opts, :tools) do
nil -> body
[] -> body
tools when is_list(tools) -> Map.put(body, :tools, tools)
end
end
# -- Response parsing -------------------------------------------------------
# Anthropic tool_use response: stop_reason "tool_use" with tool_use content blocks
defp parse_response(
:anthropic,
%{"content" => content, "stop_reason" => "tool_use"} = body
) do
tool_calls =
content
|> Enum.filter(&(&1["type"] == "tool_use"))
|> Enum.map(fn block ->
%{
"id" => block["id"],
"name" => block["name"],
"arguments" => block["input"] || %{}
}
end)
text =
content
|> Enum.find_value("", fn
%{"type" => "text", "text" => t} -> t
_ -> nil
end)
%{
content: text,
tool_calls: tool_calls,
usage: Map.get(body, "usage", %{}),
metadata: %{
backend: :http,
provider: :anthropic,
model: Map.get(body, "model"),
stop_reason: "tool_use"
}
}
end
defp parse_response(
:anthropic,
%{"content" => [%{"text" => text} | _]} = body
) do
%{
content: text,
usage: Map.get(body, "usage", %{}),
metadata: %{
backend: :http,
provider: :anthropic,
model: Map.get(body, "model"),
stop_reason: Map.get(body, "stop_reason")
}
}
end
# OpenAI tool_calls response
defp parse_response(
:openai,
%{"choices" => [%{"message" => %{"tool_calls" => tool_calls}} | _]} = body
)
when is_list(tool_calls) and tool_calls != [] do
parsed_calls =
Enum.map(tool_calls, fn tc ->
args =
case tc["function"]["arguments"] do
s when is_binary(s) ->
case Jason.decode(s) do
{:ok, map} -> map
_ -> %{}
end
map when is_map(map) ->
map
_ ->
%{}
end
%{
"id" => tc["id"],
"name" => tc["function"]["name"],
"arguments" => args
}
end)
content = get_in(body, ["choices", Access.at(0), "message", "content"]) || ""
%{
content: content,
tool_calls: parsed_calls,
usage: Map.get(body, "usage", %{}),
metadata: %{
backend: :http,
provider: :openai,
model: Map.get(body, "model")
}
}
end
defp parse_response(
:openai,
%{"choices" => [%{"message" => %{"content" => text}} | _]} = body
) do
%{
content: text,
usage: Map.get(body, "usage", %{}),
metadata: %{
backend: :http,
provider: :openai,
model: Map.get(body, "model")
}
}
end
defp parse_response(:kimi, body), do: parse_response(:openai, body)
defp parse_response(:ollama, %{"message" => %{"content" => text}} = body) do
%{
content: text,
usage: %{},
metadata: %{
backend: :http,
provider: :ollama,
model: Map.get(body, "model"),
eval_duration: Map.get(body, "eval_duration")
}
}
end
defp parse_response(_provider, body) do
%{
content: inspect(body),
usage: %{},
metadata: %{backend: :http, raw: true}
}
end
# -- Helpers ----------------------------------------------------------------
defp do_request(url, headers, body, timeout, plugins) do
if Code.ensure_loaded?(Req) do
req =
Req.new(url: url, json: body, headers: headers, receive_timeout: timeout)
|> apply_plugins(plugins)
case Req.post(req) do
{:ok, %{status: status, body: resp_body}} when status in 200..299 ->
{:ok, resp_body}
{:ok, %{status: status, body: resp_body}} ->
{:error, {:http_error, status, resp_body}}
{:error, reason} ->
{:error, {:request_failed, reason}}
end
else
{:error, :req_not_available}
end
end
defp apply_plugins(req, []), do: req
defp apply_plugins(req, plugins) when is_list(plugins) do
Enum.reduce(plugins, req, fn plugin, acc -> plugin.(acc) end)
end
defp detect_provider(opts) do
case Keyword.get(opts, :provider) do
nil ->
base_url = Keyword.get(opts, :base_url, "")
cond do
String.contains?(base_url, "anthropic") ->
:anthropic
String.contains?(base_url, "ollama") or
String.contains?(base_url, @default_ollama_port) ->
:ollama
String.contains?(base_url, "moonshot") ->
:kimi
true ->
:openai
end
provider ->
provider
end
end
defp split_system_messages(messages) do
Enum.split_with(messages, fn msg -> msg.role == :system end)
end
defp format_message(%{role: role, content: content}) do
%{role: to_string(role), content: content}
end
end