OpenAI-compatible adapter for n8n chat agents (Elixir)

Exposes self-hosted n8n chat agents behind /v1/chat/completions and
/v1/models. Model -> n8n webhook routing via a GenServer registry, so
multiple agents map to multiple models. Plug + Bandit, req for the
n8n webhook call, Bearer auth (ADAPTER_API_KEY). Ships a flake.nix
(beamPackages.mixRelease) so it can be consumed as a NixOS flake input.
This commit is contained in:
hermes-agent
2026-09-09 21:43:21 +00:00
commit 5197b6ece6
14 changed files with 511 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
.formatter.exs
/_build/
/cover/
/deps/
/doc/
/.fetch
erl_crash.dump
*.ez
n8n_openai_adapter-*.tar
/tmp/
/result
+68
View File
@@ -0,0 +1,68 @@
# n8n-openai-adapter
An OpenAI-compatible HTTP adapter that exposes self-hosted **n8n chat agents**
behind a standard `/v1/chat/completions` API, so any OpenAI client (Cursor,
LibreChat, the `openai` SDK, a custom app) can talk to your n8n agents as if
they were OpenAI models.
n8n itself does **not** ship an inbound OpenAI-compatible endpoint (its "AI
Gateway" is an outbound proxy to n8n Cloud). This small Elixir service is the
bridge: one `/v1/chat/completions` endpoint, routed to whichever n8n agent you
name in the `model` field.
## How it works
```
Your OpenAI client
POST /v1/chat/completions {"model":"scholar-agent","thread_id":"abc","messages":[...]}
|
v
n8n-openai-adapter (Plug + Bandit)
- authorize (Bearer <ADAPTER_API_KEY>)
- look up "scholar-agent" -> n8n chat webhook URL (AgentRegistry GenServer)
- take the last user message
- forward to the n8n webhook {sessionId: thread_id, action: sendMessage, chatInput}
|
v
n8n agent (its MCP tools, memory, etc. run as usual)
|
v
returns OpenAI-shaped {"choices":[{"message":{"role":"assistant","content":...}}]}
```
Multiple agents = multiple `model` names, each mapped to a different n8n webhook
in the `AGENTS` env var.
## Configuration (env vars)
| Var | Required | Purpose |
|------------------|----------|---------------------------------------------------------------------|
| `AGENTS` | yes | JSON map of `model -> n8n chat webhook URL`. e.g. `{"scholar-agent":"https://..."}` |
| `ADAPTER_API_KEY`| yes | Bearer key that OpenAI clients send. |
| `PORT` | no | HTTP port (default `8000`). |
| `CHAT_WEBHOOK_BASIC` | no | `"user:password"` if your n8n Chat Trigger is Basic-auth protected. |
## Building & running
```bash
mix deps.get
mix compile
ADAPTER_API_KEY=secret AGENTS='{"scholar-agent":"https://n8n.bueso.eu/webhook/<id>/chat"}' \
PORT=8000 mix run --no-halt
```
## Testing
```bash
MIX_ENV=test mix test
```
## Nix
The repo ships a `flake.nix` exporting `overlays.default` and a `packages.default`
(the packaged BEAM release), so it can be consumed as a flake input from your
NixOS config just like any other flake — e.g.:
```nix
inputs.n8n-openai-adapter.url = "git+https://gitea.bueso.eu/<owner>/n8n-openai-adapter";
```
+7
View File
@@ -0,0 +1,7 @@
import Config
import_config "#{config_env()}.exs"
if config_env() == :test do
config :logger, level: :warning
end
+3
View File
@@ -0,0 +1,3 @@
import Config
# Dev: no special config — all runtime settings come from env vars.
+5
View File
@@ -0,0 +1,5 @@
import Config
# Production: no hardcoded values here. All runtime config (PORT, AGENTS,
# ADAPTER_API_KEY, CHAT_WEBHOOK_BASIC) comes from the systemd EnvironmentFile
# in the NixOS service module.
+12
View File
@@ -0,0 +1,12 @@
import Config
# Test environment: set a known ADAPTER_API_KEY and the agent map.
# These are loaded into application env BEFORE the app boots (mix test boots
# the app after loading config/*.exs), so AgentRegistry starts with known agents.
config :n8n_openai_adapter,
agents: %{
"scholar-agent" => "https://n8n.bueso.eu/webhook/scholar-id/chat",
"media-agent" => "https://n8n.bueso.eu/webhook/media-id/chat"
}
System.put_env("ADAPTER_API_KEY", "test-key")
+41
View File
@@ -0,0 +1,41 @@
{
description = "OpenAI-compatible adapter exposing n8n chat agents behind /v1/chat/completions";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs =
{ self, nixpkgs, ... }:
let
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
in
{
packages = forAllSystems (
system:
let
pkgs = import nixpkgs { inherit system; };
beamPackages = pkgs.beamPackages;
in
{
default = beamPackages.mixRelease {
pname = "n8n-openai-adapter";
# Version is read from mix.exs; keep in sync there.
version = "0.1.0";
src = self;
# The release starts our Application (AgentRegistry + Bandit) on boot.
# Runtime config (PORT, AGENTS, ADAPTER_API_KEY) comes from the env
# of the systemd service that runs this release.
};
}
);
overlays.default = final: prev: {
n8n-openai-adapter = self.packages.${final.stdenv.system}.default;
};
};
}
+37
View File
@@ -0,0 +1,37 @@
defmodule N8nOpenaiAdapter.AgentRegistry do
@moduledoc """
GenServer holding the map of `model name -> n8n chat webhook URL`.
Populated at startup from config (the `AGENTS` env var). Looked up by the
router to route a `/v1/chat/completions` "model" to the right agent webhook.
"""
use GenServer
# --- Client API ---
def start_link(agents) when is_map(agents), do: GenServer.start_link(__MODULE__, agents, name: __MODULE__)
@doc "Return the webhook URL for a model, or :error if unknown."
def webhook_for(model) do
GenServer.call(__MODULE__, {:webhook_for, model})
end
@doc "Return all known model names."
def model_names do
GenServer.call(__MODULE__, :model_names)
end
# --- Server callbacks ---
@impl true
def init(agents), do: {:ok, agents}
@impl true
def handle_call({:webhook_for, model}, _from, agents) do
{:reply, Map.get(agents, model, :error), agents}
end
@impl true
def handle_call(:model_names, _from, agents) do
{:reply, Map.keys(agents), agents}
end
end
+44
View File
@@ -0,0 +1,44 @@
defmodule N8nOpenaiAdapter.Application do
@moduledoc """
OTP application entrypoint. Starts the AgentRegistry (model -> n8n webhook
mapping, loaded from config) and the Bandit HTTP server behind the Plug router.
"""
use Application
@impl true
def start(_type, _args) do
children = [
{N8nOpenaiAdapter.AgentRegistry, configured_agents()},
{Bandit, plug: N8nOpenaiAdapter.Router, scheme: :http, port: port()}
]
opts = [strategy: :one_for_one, name: N8nOpenaiAdapter.Supervisor]
Supervisor.start_link(children, opts)
end
defp port, do: String.to_integer(System.get_env("PORT", "8000"))
defp configured_agents do
# Prefer application env (:agents) — settable from config/*.exs, which loads
# before the app boots. Falls back to the AGENTS env var (JSON map of
# model -> n8n chat webhook URL).
case Application.fetch_env(:n8n_openai_adapter, :agents) do
{:ok, agents} when is_map(agents) ->
agents
_ ->
case System.get_env("AGENTS") do
nil ->
# single agent from old N8N_WEBHOOK_URL / MODEL_NAME env
model = System.get_env("MODEL_NAME", "default-agent")
case System.get_env("N8N_WEBHOOK_URL") do
nil -> %{}
webhook -> %{model => webhook}
end
json ->
Jason.decode!(json)
end
end
end
end
+172
View File
@@ -0,0 +1,172 @@
defmodule N8nOpenaiAdapter.Router do
@moduledoc """
OpenAI-compatible HTTP surface for n8n chat agents.
GET /v1/models -> lists configured agents (OpenAI shape)
POST /v1/chat/completions -> {model, messages} -> forwards last user msg
to the agent's n8n webhook, returns an
OpenAI chat.completion response.
Auth: clients send `Authorization: Bearer <ADAPTER_API_KEY>`. The webhook
call to n8n can carry its own basic auth via `CHAT_WEBHOOK_BASIC="user:pass"`.
Plug order matters: `match` -> JSON parser (consumes body) -> `dispatch`.
Parsed JSON lands in `conn.body_params`, so handlers read body_params, not
a manual body read.
"""
use Plug.Router
alias N8nOpenaiAdapter.AgentRegistry
plug(:match)
plug(Plug.Parsers, parsers: [:json], json_decoder: Jason)
plug(:dispatch)
# --- Helpers ---
# Ensure the request carries a valid ADAPTER_API_KEY bearer token.
defp authorize!(conn) do
expected = System.get_env("ADAPTER_API_KEY")
case get_req_header(conn, "authorization") do
[auth] ->
token =
case Regex.run(~r/^Bearer\s+(.+)$/i, auth) do
[_, t] -> t
_ -> auth
end
if expected && Plug.Crypto.secure_compare(token, expected) do
conn
else
{:halt, send_resp(conn, 401, Jason.encode!(%{error: %{message: "Invalid API key"}}))}
end
_ ->
{:halt, send_resp(conn, 401, Jason.encode!(%{error: %{message: "Missing Authorization header"}}))}
end
end
defp json(conn, status, body) do
conn
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(body))
end
defp error(conn, status, message) do
json(conn, status, %{error: %{message: message}})
end
# Pull the last user message content out of an OpenAI messages array.
defp last_user_text(messages) do
messages
|> Enum.reverse()
|> Enum.find_value(fn
%{"role" => "user", "content" => c} when is_binary(c) -> c
_ -> nil
end)
end
# Forward a message to the n8n chat webhook and return the assistant reply.
defp call_n8n(webhook, session_id, chat_input) do
body = %{sessionId: session_id, action: "sendMessage", chatInput: chat_input}
headers =
case System.get_env("CHAT_WEBHOOK_BASIC") do
nil -> [{"content-type", "application/json"}]
basic -> [{"content-type", "application/json"}, {"authorization", "Basic " <> Base.encode64(basic)}]
end
case Req.post(webhook, json: body, headers: headers, receive_timeout: 120_000) do
{:ok, %{status: 200, body: %{"output" => output}}} -> {:ok, output}
{:ok, %{status: s}} -> {:error, "n8n webhook returned HTTP #{s}"}
{:error, e} -> {:error, "n8n webhook error: #{Exception.message(e)}"}
end
end
# --- Routes ---
get "/v1/models" do
case authorize!(conn) do
{:halt, conn} ->
conn
conn ->
models =
Enum.map(AgentRegistry.model_names(), fn name ->
%{id: name, object: "model", created: 0, owned_by: "n8n"}
end)
json(conn, 200, %{object: "list", data: models})
end
end
post "/v1/chat/completions" do
case authorize!(conn) do
{:halt, conn} ->
conn
conn ->
handle_chat(conn)
end
end
match _ do
case authorize!(conn) do
{:halt, conn} ->
conn
conn ->
json(conn, 404, %{error: %{message: "Not found"}})
end
end
defp handle_chat(conn) do
data = conn.body_params
model = Map.get(data, "model")
messages = Map.get(data, "messages", [])
thread_id = Map.get(data, "thread_id")
session_id = if is_binary(thread_id) and thread_id != "", do: thread_id, else: "default"
cond do
not is_binary(model) or model == "" ->
error(conn, 400, "Bad request: missing or invalid \"model\"")
not is_list(messages) ->
error(conn, 400, "Bad request: expected \"messages\" array")
true ->
case last_user_text(messages) do
nil -> error(conn, 400, "No user message in request")
chat_input ->
case AgentRegistry.webhook_for(model) do
:error -> error(conn, 400, "Unknown model: #{model}")
webhook ->
case call_n8n(webhook, session_id, chat_input) do
{:ok, output} ->
resp = %{
id: "chatcmpl-#{Base.encode16(:crypto.strong_rand_bytes(12), case: :lower)}",
object: "chat.completion",
created: System.system_time(:second),
model: model,
choices: [
%{
index: 0,
message: %{role: "assistant", content: output},
finish_reason: "stop"
}
],
usage: %{prompt_tokens: 0, completion_tokens: 0, total_tokens: 0}
}
json(conn, 200, resp)
{:error, msg} ->
error(conn, 502, msg)
end
end
end
end
end
end
+29
View File
@@ -0,0 +1,29 @@
defmodule N8nOpenaiAdapter.MixProject do
use Mix.Project
def project do
[
app: :n8n_openai_adapter,
version: "0.1.0",
elixir: "~> 1.16",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
def application do
[
extra_applications: [:logger, :crypto],
mod: {N8nOpenaiAdapter.Application, []}
]
end
defp deps do
[
{:bandit, "~> 1.5"},
{:plug, "~> 1.16"},
{:jason, "~> 1.4"},
{:req, "~> 0.5"}
]
end
end
+16
View File
@@ -0,0 +1,16 @@
%{
"bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"},
"finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"},
"hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"},
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.10.0", "85af3353bfc504f5bdfe494bd92b8490f87a306dc659ee1ad0af435107e898dc", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "8b16fb72aaa7531d206a1f05e4cc85509ba531ccec7a17a22736c9c95cbb24d1"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"},
"plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"},
"req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"},
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
"thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
}
+55
View File
@@ -0,0 +1,55 @@
defmodule N8nOpenaiAdapter.RouterTest do
use ExUnit.Case, async: false
import Plug.Test
import Plug.Conn
alias N8nOpenaiAdapter.Router
test "GET /v1/models lists configured agents" do
conn =
conn(:get, "/v1/models")
|> put_req_header("authorization", "Bearer test-key")
|> Router.call(Router.init([]))
assert conn.status == 200
body = Jason.decode!(conn.resp_body)
assert body["object"] == "list"
ids = Enum.map(body["data"], & &1["id"])
assert "scholar-agent" in ids
assert "media-agent" in ids
end
test "GET /v1/models rejects a bad API key" do
conn =
conn(:get, "/v1/models")
|> put_req_header("authorization", "Bearer wrong")
|> Router.call(Router.init([]))
assert conn.status == 401
end
test "POST /v1/chat/completions with unknown model returns 400" do
conn =
conn(:post, "/v1/chat/completions",
Jason.encode!(%{"model" => "nope", "messages" => [%{"role" => "user", "content" => "hi"}]})
)
|> put_req_header("authorization", "Bearer test-key")
|> put_req_header("content-type", "application/json")
|> Router.call(Router.init([]))
assert conn.status == 400
assert Jason.decode!(conn.resp_body)["error"]["message"] == "Unknown model: nope"
end
test "POST /v1/chat/completions without a user message returns 400" do
conn =
conn(:post, "/v1/chat/completions",
Jason.encode!(%{"model" => "scholar-agent", "messages" => [%{"role" => "assistant", "content" => "hi"}]})
)
|> put_req_header("authorization", "Bearer test-key")
|> put_req_header("content-type", "application/json")
|> Router.call(Router.init([]))
assert conn.status == 400
end
end
+11
View File
@@ -0,0 +1,11 @@
System.put_env("ADAPTER_API_KEY", "test-key")
System.put_env(
"AGENTS",
Jason.encode!(%{
"scholar-agent" => "https://n8n.bueso.eu/webhook/scholar-id/chat",
"media-agent" => "https://n8n.bueso.eu/webhook/media-id/chat"
})
)
ExUnit.start()