From 5197b6ece6057052cc1abcb7e922bdbcdb525be2 Mon Sep 17 00:00:00 2001 From: hermes-agent Date: Wed, 9 Sep 2026 21:43:21 +0000 Subject: [PATCH] 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. --- .gitignore | 11 ++ README.md | 68 +++++++++ config/config.exs | 7 + config/dev.exs | 3 + config/prod.exs | 5 + config/test.exs | 12 ++ flake.nix | 41 ++++++ lib/n8n_openai_adapter/agent_registry.ex | 37 +++++ lib/n8n_openai_adapter/application.ex | 44 ++++++ lib/n8n_openai_adapter/router.ex | 172 +++++++++++++++++++++++ mix.exs | 29 ++++ mix.lock | 16 +++ test/router_test.exs | 55 ++++++++ test/test_helper.exs | 11 ++ 14 files changed, 511 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/config.exs create mode 100644 config/dev.exs create mode 100644 config/prod.exs create mode 100644 config/test.exs create mode 100644 flake.nix create mode 100644 lib/n8n_openai_adapter/agent_registry.ex create mode 100644 lib/n8n_openai_adapter/application.ex create mode 100644 lib/n8n_openai_adapter/router.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 test/router_test.exs create mode 100644 test/test_helper.exs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..30d1390ee4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.formatter.exs +/_build/ +/cover/ +/deps/ +/doc/ +/.fetch +erl_crash.dump +*.ez +n8n_openai_adapter-*.tar +/tmp/ +/result diff --git a/README.md b/README.md new file mode 100644 index 0000000000..9b6423f296 --- /dev/null +++ b/README.md @@ -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 ) + - 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//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//n8n-openai-adapter"; +``` diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000000..d2de464599 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,7 @@ +import Config + +import_config "#{config_env()}.exs" + +if config_env() == :test do + config :logger, level: :warning +end diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000000..9dd479740e --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,3 @@ +import Config + +# Dev: no special config — all runtime settings come from env vars. diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000000..145fed4af1 --- /dev/null +++ b/config/prod.exs @@ -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. diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000000..1ac9e57691 --- /dev/null +++ b/config/test.exs @@ -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") diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000000..707b09e794 --- /dev/null +++ b/flake.nix @@ -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; + }; + }; +} diff --git a/lib/n8n_openai_adapter/agent_registry.ex b/lib/n8n_openai_adapter/agent_registry.ex new file mode 100644 index 0000000000..edf85ccbe6 --- /dev/null +++ b/lib/n8n_openai_adapter/agent_registry.ex @@ -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 diff --git a/lib/n8n_openai_adapter/application.ex b/lib/n8n_openai_adapter/application.ex new file mode 100644 index 0000000000..16ea2a395c --- /dev/null +++ b/lib/n8n_openai_adapter/application.ex @@ -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 diff --git a/lib/n8n_openai_adapter/router.ex b/lib/n8n_openai_adapter/router.ex new file mode 100644 index 0000000000..7ea2bc72eb --- /dev/null +++ b/lib/n8n_openai_adapter/router.ex @@ -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 `. 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 diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000000..89b31ccdae --- /dev/null +++ b/mix.exs @@ -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 diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000000..800f7be6ba --- /dev/null +++ b/mix.lock @@ -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"}, +} diff --git a/test/router_test.exs b/test/router_test.exs new file mode 100644 index 0000000000..8f79f6967b --- /dev/null +++ b/test/router_test.exs @@ -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 diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000000..c72831190a --- /dev/null +++ b/test/test_helper.exs @@ -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()