Files
n8n-openai-adapter/test/router_test.exs
T
hermes-agent 2d46eda619 adapter: support OpenAI streaming (SSE) in /v1/chat/completions
OpenCode's @ai-sdk/openai-compatible sends stream:true and would render an
empty response because the adapter always returned a single non-streaming
chat.completion JSON body. Now when stream:true, emit OpenAI-compatible SSE
chat.completion.chunk events (role, content, [DONE]) so streaming clients
render text. Non-streaming path unchanged.

Adds a local EchoServer test that exercises the streaming route end-to-end.
2026-09-12 10:50:47 +00:00

249 lines
7.5 KiB
Elixir

defmodule N8nOpenaiAdapter.RouterTest do
use ExUnit.Case, async: false
import Plug.Test
import Plug.Conn
alias N8nOpenaiAdapter.AgentRegistry
alias N8nOpenaiAdapter.Router
# A minimal local HTTP server that answers n8n-style chat webhooks with a
# fixed {output: ...} body, so we can exercise the adapter's n8n call path
# (including the SSE streaming route) without a real n8n instance.
defmodule EchoServer do
use Plug.Router
plug(:match)
plug(:dispatch)
post "/chat" do
conn
|> put_resp_content_type("application/json")
|> send_resp(200, Jason.encode!(%{"output" => "Echo reply"}))
end
match _ do
send_resp(conn, 404, "not found")
end
end
setup_all do
port = get_free_port()
{:ok, _} = Bandit.start_link(plug: EchoServer, port: port, scheme: :http)
{:ok, port: port}
end
defp get_free_port do
{:ok, socket} = :gen_tcp.listen(0, [:binary, packet: :raw, active: false, reuseaddr: true])
{:ok, port} = :inet.port(socket)
:gen_tcp.close(socket)
port
end
setup %{port: port} do
# The app starts with an empty store (no AGENTS env seeding). Seed the two
# test agents via the registry so the base tests have something to list.
AgentRegistry.put("scholar-agent", "https://n8n.bueso.eu/webhook/scholar-id/chat")
AgentRegistry.put("media-agent", "https://n8n.bueso.eu/webhook/media-id/chat")
# A third agent pointing at the local echo server, for the streaming path.
AgentRegistry.put("echo-agent", "http://127.0.0.1:#{port}/chat")
:ok
end
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
test "POST /v1/chat/completions with stream=true emits SSE JSON chunks + [DONE]" do
conn =
conn(
:post,
"/v1/chat/completions",
Jason.encode!(%{
"model" => "echo-agent",
"stream" => true,
"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 == 200, "expected 200, got #{conn.status}: #{conn.resp_body}"
body = conn.resp_body
assert {"content-type", ct} = List.keyfind(conn.resp_headers, "content-type", 0)
assert String.starts_with?(ct, "text/event-stream")
# Each SSE event is `data: <json>\n\n`; split and check the JSON is valid
events = String.split(body, "\n\n") |> Enum.reject(&(&1 == ""))
data_events = Enum.filter(events, &String.starts_with?(&1, "data: "))
assert Enum.any?(data_events, &String.ends_with?(&1, "[DONE]"))
# Parse a non-DONE chunk and require the OpenAI chunk shape
chunk_event = Enum.find(data_events, &(not String.ends_with?(&1, "[DONE]")))
chunk = Jason.decode!(String.replace_prefix(chunk_event, "data: ", ""))
assert chunk["object"] == "chat.completion.chunk"
assert is_list(chunk["choices"])
# The content delta chunk carries the reply text
content_chunks =
Enum.filter(data_events, fn ev ->
decoded =
case Jason.decode(String.replace_prefix(ev, "data: ", "")) do
{:ok, j} -> j
_ -> :error
end
case decoded do
%{"choices" => [choice]} ->
match?(%{"content" => c} when is_binary(c), Map.get(choice, "delta"))
_ ->
false
end
end)
assert length(content_chunks) == 1, "content_chunks=#{length(content_chunks)}"
end
test "GET /admin serves the web admin page (no auth needed to load the form)" do
conn = conn(:get, "/admin") |> Router.call(Router.init([]))
assert conn.status == 200
assert conn.resp_body =~ "n8n OpenAI Adapter"
assert conn.resp_body =~ "admin/agents"
end
# --- Admin API ---
test "GET /admin/agents lists agents (admin key)" do
conn =
conn(:get, "/admin/agents")
|> put_req_header("authorization", "Bearer test-admin-key")
|> Router.call(Router.init([]))
assert conn.status == 200
body = Jason.decode!(conn.resp_body)
assert Map.has_key?(body["agents"], "scholar-agent")
end
test "GET /admin/agents rejects a non-admin key" do
conn =
conn(:get, "/admin/agents")
|> put_req_header("authorization", "Bearer test-key")
|> Router.call(Router.init([]))
assert conn.status == 401
end
test "POST /admin/agents adds an agent" do
conn =
conn(
:post,
"/admin/agents",
Jason.encode!(%{
"model" => "new-agent",
"webhook" => "https://n8n.bueso.eu/webhook/new/chat"
})
)
|> put_req_header("authorization", "Bearer test-admin-key")
|> put_req_header("content-type", "application/json")
|> Router.call(Router.init([]))
assert conn.status == 200
assert Jason.decode!(conn.resp_body)["ok"] == true
# now visible in /v1/models
conn2 =
conn(:get, "/v1/models")
|> put_req_header("authorization", "Bearer test-key")
|> Router.call(Router.init([]))
ids = Enum.map(Jason.decode!(conn2.resp_body)["data"], & &1["id"])
assert "new-agent" in ids
end
test "DELETE /admin/agents removes an agent" do
# add then delete
conn =
conn(
:post,
"/admin/agents",
Jason.encode!(%{
"model" => "temp-agent",
"webhook" => "https://n8n.bueso.eu/webhook/temp/chat"
})
)
|> put_req_header("authorization", "Bearer test-admin-key")
|> put_req_header("content-type", "application/json")
|> Router.call(Router.init([]))
assert conn.status == 200
conn2 =
conn(:delete, "/admin/agents/temp-agent")
|> put_req_header("authorization", "Bearer test-admin-key")
|> Router.call(Router.init([]))
assert conn2.status == 200
conn3 =
conn(:delete, "/admin/agents/temp-agent")
|> put_req_header("authorization", "Bearer test-admin-key")
|> Router.call(Router.init([]))
assert conn3.status == 404
end
end