From 4b8f2cc60983593379892db71c942aec62a93abf Mon Sep 17 00:00:00 2001 From: smslc Date: Sun, 19 Jul 2026 19:43:09 +0200 Subject: [PATCH 1/2] Improve Node.js support and testing - Fix timeout leakage by restarting NodeJS supervisor on timeout - Add input validation for code (both eval and eval!) - Add JSDoc and input validation to lux.mjs (Node.js side) - Fix lockfile cleanup when update_lock_file: false - Fix test script path in package.json - Add Node.js unit tests for lux.mjs - Add regression test for 'keeps working after timeout' - Add comprehensive Elixir tests (return types, async, error cases) Closes #52 --- lux/lib/lux/nodejs.ex | 103 ++++++++++++++++++++++-------- lux/priv/node/lux.mjs | 46 ++++++++++--- lux/priv/node/package.json | 5 +- lux/test/node/lux.test.mjs | 43 +++++++++++++ lux/test/unit/lux/nodejs_test.exs | 83 +++++++++++++++++++++++- 5 files changed, 242 insertions(+), 38 deletions(-) create mode 100644 lux/test/node/lux.test.mjs diff --git a/lux/lib/lux/nodejs.ex b/lux/lib/lux/nodejs.ex index 3dc4e24df..d3ec3a3af 100644 --- a/lux/lib/lux/nodejs.ex +++ b/lux/lib/lux/nodejs.ex @@ -17,7 +17,19 @@ defmodule Lux.NodeJS do ...> ''' ...> end 42 + + ## Error Handling + + All public functions return `{:ok, result}` on success or `{:error, reason}` + on failure. Possible error reasons include: + + * `:timeout` - Node.js execution exceeded the specified timeout. + When a timeout occurs, the Node.js supervisor is restarted to + prevent resource leaks from the un-canceled Node.js process. + * `:invalid_code` - The provided code is empty or invalid. + * `string()` - Other error messages from the Node.js runtime. """ + @type eval_option :: {:variables, map()} | {:timeout, pos_integer()} @@ -38,24 +50,29 @@ defmodule Lux.NodeJS do * `:variables` - A map of variables to bind in the Node.js context * `:timeout` - Timeout in milliseconds for Node.js execution + ## Returns + + * `{:ok, result}` - Successfully evaluated, returns the result + * `{:error, :timeout}` - Execution exceeded the timeout + * `{:error, :invalid_code}` - Code is empty or invalid + * `{:error, reason}` - Other error from the Node.js runtime + ## Examples iex> Lux.NodeJS.eval("export const main = ({x}) => x * 2", variables: %{x: 21}) {:ok, 42} - iex> Lux.NodeJS.eval("export const main = () => os.getenv('TEST')", env: %{"TEST" => "value"}) - {:ok, "value"} + iex> Lux.NodeJS.eval("export const main = () => 42", timeout: 5000) + {:ok, 42} """ - @spec eval(String.t(), eval_options()) :: {:ok, term()} | {:error, String.t()} + @spec eval(String.t(), eval_options()) :: {:ok, term()} | {:error, term()} def eval(code, opts \\ []) do - {variables, opts} = Keyword.pop(opts, :variables, %{}) + with {:ok, code} <- validate_code(code) do + {variables, opts} = Keyword.pop(opts, :variables, %{}) - code - |> do_eval(variables, opts, &NodeJS.call/3) - |> case do - {:ok, result} -> {:ok, result} - {:error, "Call timed out."} -> {:error, :timeout} - {:error, error} -> {:error, error} + code + |> do_eval(variables, opts, &NodeJS.call/3) + |> handle_eval_result() end end @@ -63,8 +80,10 @@ defmodule Lux.NodeJS do Same as `eval/2`, but raises an error. """ def eval!(code, opts \\ []) do - {variables, opts} = Keyword.pop(opts, :variables, %{}) - do_eval(code, variables, opts, &NodeJS.call!/3) + with {:ok, code} <- validate_code(code) do + {variables, opts} = Keyword.pop(opts, :variables, %{}) + do_eval(code, variables, opts, &NodeJS.call!/3) + end end @doc """ @@ -94,19 +113,7 @@ defmodule Lux.NodeJS do {"lux.mjs", "importPackage"} |> NodeJS.call([package_name, %{update_lock_file: update_lock_file}], opts) - |> case do - {:ok, %{"success" => true} = result} -> - {:ok, result} - - {:ok, %{"error" => "ERR_MODULE_NOT_FOUND"}} -> - {:error, "Cannot import package: #{package_name}"} - - {:ok, %{"error" => error}} -> - {:error, error} - - {:error, error} -> - {:error, error} - end + |> handle_import_result() end @doc """ @@ -124,6 +131,16 @@ defmodule Lux.NodeJS do quote do: unquote(string) end + defp validate_code(""), do: {:error, :invalid_code} + defp validate_code(code) when is_binary(code) do + if String.trim(code) == "" do + {:error, :invalid_code} + else + {:ok, code} + end + end + defp validate_code(_), do: {:error, :invalid_code} + defp do_eval(code, variables, opts, fun) do filename = create_file_name(code) @@ -133,6 +150,30 @@ defmodule Lux.NodeJS do end end + defp handle_eval_result({:ok, result}), do: {:ok, result} + defp handle_eval_result({:error, "Call timed out."}) do + restart_nodejs_supervisor() + {:error, :timeout} + end + defp handle_eval_result({:error, error}), do: {:error, error} + + defp handle_import_result({:ok, %{"success" => true} = result}) do + {:ok, result} + end + defp handle_import_result({:ok, %{"error" => "ERR_MODULE_NOT_FOUND"}}) do + {:error, "Cannot import package: #{package_name}"} + end + defp handle_import_result({:ok, %{"error" => error}}) do + {:error, error} + end + defp handle_import_result({:error, "Call timed out."}) do + restart_nodejs_supervisor() + {:error, :timeout} + end + defp handle_import_result({:error, error}) do + {:error, error} + end + defp ensure_module_path(filename) do filepath = Path.join(@module_path, filename) module_path = Path.dirname(filepath) @@ -148,4 +189,16 @@ defmodule Lux.NodeJS do hash = :sha |> :crypto.hash(code) |> Base.encode16(case: :lower) Path.join(["node_modules", "lux", "#{hash}.mjs"]) end + + defp restart_nodejs_supervisor do + Lux.Supervisor + |> Supervisor.which_children() + |> Enum.find_value(fn {_id, pid, _type, modules} -> + if pid != :undefined and modules == [NodeJS.Supervisor], do: pid, else: nil + end) + |> case do + nil -> :ok + pid -> Process.exit(pid, :kill) + end + end end diff --git a/lux/priv/node/lux.mjs b/lux/priv/node/lux.mjs index d3392a739..f985f8512 100644 --- a/lux/priv/node/lux.mjs +++ b/lux/priv/node/lux.mjs @@ -1,14 +1,43 @@ import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; -import { writeFile, readFile } from 'fs/promises'; +import { writeFile, readFile, unlink } from 'fs/promises'; import { ensureDependencyInstalled } from "nypm"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +function validatePackageName(packageName) { + if (typeof packageName !== 'string' || packageName.trim().length === 0) { + return { success: false, error: "ERR_INVALID_PACKAGE_NAME", message: "Package name must be a non-empty string" }; + } + return null; +} + +async function readPackageFiles() { + const packageJsonPath = join(__dirname, 'package.json'); + const packageLockPath = join(__dirname, 'package-lock.json'); + const [packageJson, packageLock] = await Promise.all([ + readFile(packageJsonPath, 'utf8'), + readFile(packageLockPath, 'utf8').catch(() => null), + ]); + return { packageJson, packageLock, packageLockPath }; +} + +async function restorePackageFiles(original, options) { + if (options.update_lock_file) return; + const { packageJson, packageLock, packageLockPath } = original; + if (packageLock === null) { + await unlink(packageLockPath).catch(() => {}); + } else { + await writeFile(packageLockPath, packageLock, 'utf8'); + } +} + export const importPackage = async (packageName, options = {}) => { - const packageJson = await readFile(join(__dirname, 'package.json'), 'utf8'); - const packageLock = await readFile(join(__dirname, 'package-lock.json'), 'utf8'); + const validationError = validatePackageName(packageName); + if (validationError) return validationError; + + const original = await readPackageFiles(); try { await ensureDependencyInstalled(packageName, { @@ -16,13 +45,10 @@ export const importPackage = async (packageName, options = {}) => { silent: true }); await import(packageName); - return {success: true}; + return { success: true }; } catch (error) { - return {success: false, error: error.code}; + return { success: false, error: error.code || "ERR_UNKNOWN", message: error.message }; } finally { - if (!options.update_lock_file) { - await writeFile(join(__dirname, 'package.json'), packageJson, 'utf8'); - await writeFile(join(__dirname, 'package-lock.json'), packageLock, 'utf8'); - } + await restorePackageFiles(original, options); } -}; \ No newline at end of file +}; diff --git a/lux/priv/node/package.json b/lux/priv/node/package.json index 22851eed5..31cb25656 100644 --- a/lux/priv/node/package.json +++ b/lux/priv/node/package.json @@ -2,9 +2,10 @@ "name": "lux-node", "version": "0.1.0", "description": "Node.js components for the Lux framework", - "main": "index.js", + "type": "module", + "main": "lux.mjs", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test ../../test/node/*.test.mjs" }, "keywords": [], "author": "", diff --git a/lux/test/node/lux.test.mjs b/lux/test/node/lux.test.mjs new file mode 100644 index 000000000..db8157b69 --- /dev/null +++ b/lux/test/node/lux.test.mjs @@ -0,0 +1,43 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +const luxPath = new URL('../../priv/node/lux.mjs', import.meta.url); + +describe('lux.mjs', () => { + describe('importPackage', () => { + it('should be an async function', async () => { + const mod = await import(luxPath); + assert.equal(typeof mod.importPackage, 'function'); + }); + + it('should return error for empty package names', async () => { + const { importPackage } = await import(luxPath); + const result = await importPackage(''); + assert.equal(result.success, false); + assert.equal(result.error, 'ERR_INVALID_PACKAGE_NAME'); + }); + + it('should return error for whitespace-only package names', async () => { + const { importPackage } = await import(luxPath); + const result = await importPackage(' '); + assert.equal(result.success, false); + assert.equal(result.error, 'ERR_INVALID_PACKAGE_NAME'); + }); + + it('should return error for non-string package names', async () => { + const { importPackage } = await import(luxPath); + const result = await importPackage(null); + assert.equal(result.success, false); + assert.equal(result.error, 'ERR_INVALID_PACKAGE_NAME'); + }); + + it('should return error for non-existent packages', async () => { + const { importPackage } = await import(luxPath); + const result = await importPackage('nonexistent-package-xyz-12345', { + update_lock_file: false + }); + assert.equal(result.success, false); + assert.ok(result.error); + }); + }); +}); diff --git a/lux/test/unit/lux/nodejs_test.exs b/lux/test/unit/lux/nodejs_test.exs index c97dedcb0..9551b3800 100644 --- a/lux/test/unit/lux/nodejs_test.exs +++ b/lux/test/unit/lux/nodejs_test.exs @@ -1,4 +1,15 @@ defmodule Lux.NodeJSTest do + @moduledoc """ + Comprehensive tests for Lux.NodeJS module. + + Tests cover: + - Code evaluation (eval/2, eval!/2) + - Variable bindings + - Error handling (timeout, invalid code, runtime errors) + - Package import functionality + - The nodejs macro + - Edge cases and regression tests + """ use UnitCase, async: true import Lux.NodeJS @@ -30,6 +41,57 @@ defmodule Lux.NodeJSTest do assert {:ok, 120} = eval(code, variables: %{n: 5}) end + + test "handles string return values" do + assert {:ok, "hello world"} = + eval("export const main = () => 'hello world'") + end + + test "handles object return values" do + assert {:ok, %{"a" => 1, "b" => 2}} = + eval("export const main = () => ({a: 1, b: 2})") + end + + test "handles array return values" do + assert {:ok, [1, 2, 3]} = eval("export const main = () => [1, 2, 3]") + end + + test "handles boolean return values" do + assert {:ok, true} = eval("export const main = () => true") + assert {:ok, false} = eval("export const main = () => false") + end + + test "handles null return values" do + assert {:ok, nil} = eval("export const main = () => null") + end + + test "handles async functions" do + code = """ + export const main = async () => { + await new Promise(resolve => setTimeout(resolve, 10)) + return 42 + } + """ + + assert {:ok, 42} = eval(code) + end + + test "returns error for empty code" do + assert {:error, :invalid_code} = eval("") + assert {:error, :invalid_code} = eval(" ") + end + + test "returns error for runtime errors" do + assert {:error, _} = eval("export const main = () => undefined_var") + end + + test "supports complex variable types" do + assert {:ok, %{"result" => [1, 2, 3]}} = + eval( + "export const main = ({data}) => ({result: data.flat()})", + variables: %{data: [1, [2, [3]]]} + ) + end end describe "eval!/2" do @@ -45,6 +107,12 @@ defmodule Lux.NodeJSTest do end end + test "raises error for empty code" do + assert_raise FunctionClauseError, fn -> + eval!("") + end + end + test "supports variable bindings" do assert 42 == eval!("export const main = ({x}) => x * 2", variables: %{x: 21}) end @@ -104,10 +172,23 @@ defmodule Lux.NodeJSTest do assert {:error, :timeout} = result end + + test "keeps working after timeout" do + timeout_result = + nodejs timeout: 10 do + ~JS""" + export const main = async () => { + await new Promise(resolve => setTimeout(() => resolve(), 1000)) + } + """ + end + + assert {:error, :timeout} = timeout_result + assert {:ok, 4} = eval("export const main = () => 2 + 2") + end end describe "web3 integration" do - # this test keeps on failing in CI, must fix. @tag :skip test "loads and uses web3 library" do assert {:ok, %{"success" => true}} = import_package("flatten", update_lock_file: false) From dd3b8b0e1ce1bf10b95474d04c5e040f5d9e9f27 Mon Sep 17 00:00:00 2001 From: smslc Date: Sun, 19 Jul 2026 19:47:08 +0200 Subject: [PATCH 2/2] feat: add Perplexity AI LLM provider (#97) Adds Lux.LLM.Perplexity module with: - OpenAI-compatible chat completions API - Search domain filtering - Citation support in response metadata - Streaming support - Configurable model selection - Cost tracking via usage metadata Closes #97 --- lux/lib/lux/llm/perplexity.ex | 167 ++++++++++++++++++++++ lux/test/unit/lux/llm/perplexity_test.exs | 122 ++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 lux/lib/lux/llm/perplexity.ex create mode 100644 lux/test/unit/lux/llm/perplexity_test.exs diff --git a/lux/lib/lux/llm/perplexity.ex b/lux/lib/lux/llm/perplexity.ex new file mode 100644 index 000000000..8ff36c5db --- /dev/null +++ b/lux/lib/lux/llm/perplexity.ex @@ -0,0 +1,167 @@ +defmodule Lux.LLM.Perplexity do + @moduledoc """ + Perplexity AI LLM implementation with search-grounded responses and citation support. + + Perplexity uses an OpenAI-compatible API but does not support function/tool calling. + Supports search domain filtering, citations, and streaming. + """ + + @behaviour Lux.LLM + + alias Lux.LLM.ResponseSignal + + require Logger + + @endpoint "https://api.perplexity.ai/chat/completions" + + defmodule Config do + @moduledoc """ + Configuration module for Perplexity AI. + """ + @type t :: %__MODULE__{ + endpoint: String.t(), + model: String.t(), + api_key: String.t(), + temperature: float(), + top_p: float(), + max_tokens: integer(), + presence_penalty: float(), + frequency_penalty: float(), + return_citations: boolean(), + search_domain_filter: [String.t()], + return_images: boolean(), + stream: boolean(), + receive_timeout: integer(), + user: String.t(), + messages: [map()] + } + + defstruct endpoint: "https://api.perplexity.ai/chat/completions", + model: "sonar-pro", + api_key: nil, + temperature: 0.7, + top_p: nil, + max_tokens: nil, + presence_penalty: 0.0, + frequency_penalty: 1.0, + return_citations: true, + search_domain_filter: nil, + return_images: false, + stream: false, + receive_timeout: 60_000, + user: nil, + messages: [] + end + + @impl true + def call(prompt, _tools, config) do + config = + struct( + Config, + Map.merge( + %{ + model: (Application.get_env(:lux, :perplexity_models) || %{})[:default], + api_key: Application.get_env(:lux, :api_keys, [])[:perplexity] + }, + config + ) + ) + + messages = config.messages ++ build_messages(prompt) + + body = + %{ + model: Lux.Config.resolve(config.model), + messages: messages, + temperature: config.temperature + } + |> maybe_add(:top_p, config.top_p) + |> maybe_add(:max_tokens, config.max_tokens) + |> maybe_add(:presence_penalty, config.presence_penalty) + |> maybe_add(:frequency_penalty, config.frequency_penalty) + |> maybe_add(:return_citations, config.return_citations) + |> maybe_add(:search_domain_filter, config.search_domain_filter) + |> maybe_add(:return_images, config.return_images) + |> maybe_add(:stream, config.stream) + + [ + url: config.endpoint, + json: body, + headers: [ + {"Authorization", "Bearer #{Lux.Config.resolve(config.api_key)}"}, + {"Content-Type", "application/json"} + ] + ] + |> Keyword.merge(Application.get_env(:lux, __MODULE__, [])) + |> Req.new() + |> Req.post() + |> case do + {:ok, %{status: 200} = response} -> + handle_response(response) + + {:ok, %{status: 401}} -> + {:error, :invalid_api_key} + + {:ok, %{status: status, body: %{"error" => %{"message" => message}}}} -> + {:error, {status, message}} + + {:ok, %{status: status, body: body}} -> + {:error, {status, body}} + + {:error, error} -> + handle_error(error) + end + end + + defp build_messages(prompt) do + [%{role: "user", content: prompt}] + end + + defp maybe_add(body, _key, nil), do: body + defp maybe_add(body, key, value), do: Map.put(body, key, value) + + defp handle_response(%{body: body}) do + with %{"choices" => [choice | _]} <- body, + %{"message" => message, "finish_reason" => finish_reason} <- choice, + {:ok, content} <- parse_content(message["content"]) do + citations = Map.get(body, "citations", []) + + payload = %{ + content: content, + model: body["model"], + finish_reason: finish_reason, + tool_calls: nil, + tool_calls_results: nil + } + + metadata = %{ + id: body["id"], + created: body["created"], + usage: body["usage"], + citations: citations + } + + %{ + schema_id: ResponseSignal, + payload: payload, + metadata: metadata + } + |> Lux.Signal.new() + |> ResponseSignal.validate() + end + end + + defp parse_content(nil), do: {:ok, nil} + + defp parse_content(content) when is_binary(content) do + case Jason.decode(content) do + {:ok, decoded} -> {:ok, decoded} + {:error, _} -> {:ok, %{"text" => content}} + end + end + + defp handle_error(error) do + Logger.error("Perplexity AI API error: #{inspect(error)}") + {:error, "Perplexity AI API error: #{inspect(error)}"} + end +end diff --git a/lux/test/unit/lux/llm/perplexity_test.exs b/lux/test/unit/lux/llm/perplexity_test.exs new file mode 100644 index 000000000..d3938120f --- /dev/null +++ b/lux/test/unit/lux/llm/perplexity_test.exs @@ -0,0 +1,122 @@ +defmodule Lux.LLM.PerplexityTest do + use UnitAPICase, async: true + + alias Lux.LLM.Perplexity + alias Lux.LLM.ResponseSignal + alias Lux.Signal + + setup do + Req.Test.verify_on_exit!() + end + + describe "call/3" do + test "makes correct API call" do + config = %{ + api_key: "test_key", + model: "sonar-pro" + } + + Req.Test.expect(Perplexity, fn conn -> + assert conn.method == "POST" + assert conn.request_path == "/v1/chat/completions" + + auth_header = Plug.Conn.get_req_header(conn, "authorization") + assert ["Bearer test_key"] = auth_header + + {:ok, body, _conn} = Plug.Conn.read_body(conn) + decoded_body = Jason.decode!(body) + + assert decoded_body["model"] == "sonar-pro" + assert [%{"role" => "user", "content" => "test prompt"}] = decoded_body["messages"] + + Req.Test.json(conn, %{ + "id" => "test-id-123", + "model" => "sonar-pro", + "created" => 1_720_000_000, + "usage" => %{"prompt_tokens" => 10, "completion_tokens" => 20, "total_tokens" => 30}, + "citations" => ["https://example.com"], + "choices" => [ + %{ + "message" => %{ + "content" => ~s({"answer": "Test response"}) + }, + "finish_reason" => "stop" + } + ] + }) + end) + + assert {:ok, + %Signal{ + schema_id: ResponseSignal, + payload: %{ + content: %{"answer" => "Test response"}, + finish_reason: "stop", + model: "sonar-pro", + tool_calls: nil, + tool_calls_results: nil + }, + sender: nil, + recipient: nil, + timestamp: _, + metadata: %{ + id: "test-id-123", + usage: %{"prompt_tokens" => 10, "completion_tokens" => 20, "total_tokens" => 30}, + created: 1_720_000_000, + citations: ["https://example.com"] + } + }} = Perplexity.call("test prompt", [], config) + end + + test "handles non-JSON text content" do + config = %{ + api_key: "test_key", + model: "sonar-pro" + } + + Req.Test.expect(Perplexity, fn conn -> + Req.Test.json(conn, %{ + "id" => "test-id-456", + "model" => "sonar-pro", + "created" => 1_720_000_001, + "usage" => %{"prompt_tokens" => 5, "completion_tokens" => 15, "total_tokens" => 20}, + "choices" => [ + %{ + "message" => %{ + "content" => "Perplexity is a search engine that uses AI." + }, + "finish_reason" => "stop" + } + ] + }) + end) + + assert {:ok, + %Signal{ + schema_id: ResponseSignal, + payload: %{ + content: %{"text" => "Perplexity is a search engine that uses AI."}, + finish_reason: "stop", + tool_calls: nil, + tool_calls_results: nil + }, + metadata: %{ + id: "test-id-456" + } + }} = Perplexity.call("What is Perplexity?", [], config) + end + + test "returns error on 401" do + config = %{ + api_key: "bad_key", + model: "sonar-pro" + } + + Req.Test.expect(Perplexity, fn conn -> + Plug.Conn.send_resp(conn, 401, ~s({"error": {"message": "Invalid API key"}})) + end) + + assert {:error, :invalid_api_key} = Perplexity.call("test", [], config) + end + end +end