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:
+11
@@ -0,0 +1,11 @@
|
|||||||
|
.formatter.exs
|
||||||
|
/_build/
|
||||||
|
/cover/
|
||||||
|
/deps/
|
||||||
|
/doc/
|
||||||
|
/.fetch
|
||||||
|
erl_crash.dump
|
||||||
|
*.ez
|
||||||
|
n8n_openai_adapter-*.tar
|
||||||
|
/tmp/
|
||||||
|
/result
|
||||||
@@ -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";
|
||||||
|
```
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import Config
|
||||||
|
|
||||||
|
import_config "#{config_env()}.exs"
|
||||||
|
|
||||||
|
if config_env() == :test do
|
||||||
|
config :logger, level: :warning
|
||||||
|
end
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import Config
|
||||||
|
|
||||||
|
# Dev: no special config — all runtime settings come from env vars.
|
||||||
@@ -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.
|
||||||
@@ -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")
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"},
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user