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.
249 lines
7.5 KiB
Elixir
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
|