diff --git a/CHANGELOG.md b/CHANGELOG.md index 1003929..c938bae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- `Langfuse.Prompt.get/2` now returns prompt data correctly; the underlying HTTP call was sending the prompt name as a query parameter instead of in the URL path + ### Changed - Relaxed Elixir version constraint from `~> 1.19` to `~> 1.17` to support projects on Elixir 1.17 and 1.18 ### Added +- `:resolve` option for `Langfuse.Prompt.get/2`, `Langfuse.Prompt.fetch/2`, `Langfuse.Client.get_prompt/2`, and `Langfuse.HTTP.get_prompt/2` to control server-side prompt dependency resolution - GitHub Actions CI with matrix testing across Elixir 1.17/OTP 26, 1.18/OTP 27, and 1.19/OTP 28 ## [0.1.0] - 2025-11-29 diff --git a/devenv.nix b/devenv.nix index f31f56e..0c84606 100644 --- a/devenv.nix +++ b/devenv.nix @@ -8,6 +8,7 @@ packages = [ pkgs.git + pkgs.nodejs ]; enterShell = '' diff --git a/lib/langfuse/client.ex b/lib/langfuse/client.ex index 6374966..95e7b8d 100644 --- a/lib/langfuse/client.ex +++ b/lib/langfuse/client.ex @@ -152,7 +152,11 @@ defmodule Langfuse.Client do @spec update_prompt_labels(String.t(), pos_integer(), keyword()) :: response() def update_prompt_labels(name, version, opts) do body = %{labels: Keyword.fetch!(opts, :labels)} - patch("/api/public/v2/prompts/#{URI.encode(name)}/versions/#{version}", body) + + patch( + "/api/public/v2/prompts/#{URI.encode(name, &URI.char_unreserved?/1)}/versions/#{version}", + body + ) end @doc """ @@ -167,6 +171,7 @@ defmodule Langfuse.Client do * `:version` - Specific version number to fetch * `:label` - Label to fetch (e.g., "production", "latest") + * `:resolve` - Whether to resolve prompt dependencies before returning (defaults to `true` on server) ## Examples @@ -184,8 +189,9 @@ defmodule Langfuse.Client do [] |> maybe_add_param(:version, opts[:version]) |> maybe_add_param(:label, opts[:label]) + |> maybe_add_param(:resolve, opts[:resolve]) - get("/api/public/v2/prompts/#{URI.encode(name)}", params) + get("/api/public/v2/prompts/#{URI.encode(name, &URI.char_unreserved?/1)}", params) end @doc """ @@ -199,7 +205,7 @@ defmodule Langfuse.Client do """ @spec get_dataset(String.t()) :: response() def get_dataset(name) do - get("/api/public/v2/datasets/#{URI.encode(name)}") + get("/api/public/v2/datasets/#{URI.encode(name, &URI.char_unreserved?/1)}") end @doc """ @@ -837,7 +843,7 @@ defmodule Langfuse.Client do """ @spec delete_dataset(String.t()) :: :ok | {:error, term()} def delete_dataset(name) do - delete("/api/public/v2/datasets/#{URI.encode(name)}") + delete("/api/public/v2/datasets/#{URI.encode(name, &URI.char_unreserved?/1)}") end @doc """ diff --git a/lib/langfuse/http.ex b/lib/langfuse/http.ex index f50b4ad..b1cacbd 100644 --- a/lib/langfuse/http.ex +++ b/lib/langfuse/http.ex @@ -89,17 +89,19 @@ defmodule Langfuse.HTTP do * `:version` - Specific version number * `:label` - Label to fetch (e.g., "production") + * `:resolve` - Whether to resolve prompt dependencies before returning (defaults to `true` on server) """ @impl true @spec get_prompt(String.t(), keyword()) :: response() def get_prompt(name, opts \\ []) do params = - [name: name] + [] |> maybe_add(:version, opts[:version]) |> maybe_add(:label, opts[:label]) + |> maybe_add(:resolve, opts[:resolve]) - get(@prompts_path, params) + get("#{@prompts_path}/#{URI.encode(name, &URI.char_unreserved?/1)}", params) end @doc """ diff --git a/lib/langfuse/prompt.ex b/lib/langfuse/prompt.ex index cf029db..9eb988e 100644 --- a/lib/langfuse/prompt.ex +++ b/lib/langfuse/prompt.ex @@ -84,6 +84,7 @@ defmodule Langfuse.Prompt do * `:version` - Specific version number to fetch. * `:label` - Label to fetch (e.g., "production", "latest"). + * `:resolve` - Whether to resolve prompt dependencies before returning (defaults to `true` on server). * `:cache_ttl` - Cache TTL in milliseconds. Defaults to 60,000 (1 minute). * `:fallback` - Fallback prompt struct or template to use if fetch fails. Can be a `%Langfuse.Prompt{}` struct or a string template. @@ -188,6 +189,7 @@ defmodule Langfuse.Prompt do * `:version` - Specific version number to fetch. * `:label` - Label to fetch (e.g., "production", "latest"). + * `:resolve` - Whether to resolve prompt dependencies before returning (defaults to `true` on server). ## Examples @@ -324,10 +326,8 @@ defmodule Langfuse.Prompt do """ @spec invalidate(String.t(), keyword()) :: :ok def invalidate(name, opts \\ []) do - key = cache_key(name, opts) - if opts[:version] || opts[:label] do - delete_cache_key(key) + delete_cache_entries(name, opts[:version], opts[:label]) else delete_cache_by_name(name) end @@ -361,21 +361,28 @@ defmodule Langfuse.Prompt do defp cache_key(name, opts) do version = opts[:version] label = opts[:label] - {name, version, label} + resolve = if(opts[:resolve] == false, do: false, else: true) + {name, version, label, resolve} end - defp delete_cache_key(key) do - :ets.delete(:langfuse_prompt_cache, key) + defp delete_cache_entries(name, version, label) do + :ets.match_delete( + :langfuse_prompt_cache, + {{name, cache_match(version), cache_match(label), :_}, :_, :_} + ) rescue ArgumentError -> :ok end defp delete_cache_by_name(name) do - :ets.match_delete(:langfuse_prompt_cache, {{name, :_, :_}, :_, :_}) + :ets.match_delete(:langfuse_prompt_cache, {{name, :_, :_, :_}, :_, :_}) rescue ArgumentError -> :ok end + defp cache_match(nil), do: :_ + defp cache_match(value), do: value + defp get_cached(key) do case :ets.lookup(:langfuse_prompt_cache, key) do [{^key, prompt, expires_at}] -> diff --git a/test/langfuse/http_test.exs b/test/langfuse/http_test.exs index c0b0e73..a29b150 100644 --- a/test/langfuse/http_test.exs +++ b/test/langfuse/http_test.exs @@ -153,9 +153,7 @@ defmodule Langfuse.HTTPTest do end test "get_prompt/2 fetches prompt by name", %{bypass: bypass} do - Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts", fn conn -> - assert conn.query_string =~ "name=test-prompt" - + Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts/test-prompt", fn conn -> conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.resp( @@ -177,8 +175,7 @@ defmodule Langfuse.HTTPTest do end test "get_prompt/2 with version option", %{bypass: bypass} do - Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts", fn conn -> - assert conn.query_string =~ "name=test-prompt" + Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts/test-prompt", fn conn -> assert conn.query_string =~ "version=3" conn @@ -194,8 +191,7 @@ defmodule Langfuse.HTTPTest do end test "get_prompt/2 with label option", %{bypass: bypass} do - Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts", fn conn -> - assert conn.query_string =~ "name=test-prompt" + Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts/test-prompt", fn conn -> assert conn.query_string =~ "label=staging" conn @@ -210,8 +206,24 @@ defmodule Langfuse.HTTPTest do assert "staging" in prompt["labels"] end + test "get_prompt/2 with resolve option", %{bypass: bypass} do + Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts/test-prompt", fn conn -> + assert conn.query_string =~ "resolve=false" + + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.resp( + 200, + Jason.encode!(%{name: "test-prompt", version: 1, prompt: "raw {{dep}}"}) + ) + end) + + assert {:ok, prompt} = Langfuse.HTTP.get_prompt("test-prompt", resolve: false) + assert prompt["prompt"] == "raw {{dep}}" + end + test "get_prompt/2 handles not found", %{bypass: bypass} do - Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts", fn conn -> + Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts/nonexistent", fn conn -> conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.resp(404, Jason.encode!(%{error: "Prompt not found"})) diff --git a/test/langfuse/prompt_test.exs b/test/langfuse/prompt_test.exs index 616fdd0..580f4e2 100644 --- a/test/langfuse/prompt_test.exs +++ b/test/langfuse/prompt_test.exs @@ -200,7 +200,7 @@ defmodule Langfuse.PromptTest do tags: [] } - key = {"cached", nil, nil} + key = {"cached", nil, nil, true} expires_at = System.monotonic_time(:millisecond) + 60_000 :ets.insert(:langfuse_prompt_cache, {key, prompt, expires_at}) @@ -210,6 +210,70 @@ defmodule Langfuse.PromptTest do assert :ets.lookup(:langfuse_prompt_cache, key) == [] end + + test "invalidates versioned cache entries across resolve variants" do + try do + :ets.new(:langfuse_prompt_cache, [:set, :public, :named_table]) + rescue + ArgumentError -> :ok + end + + prompt = %Prompt{ + name: "cached", + version: 2, + type: :text, + prompt: "test", + labels: [], + tags: [] + } + + expires_at = System.monotonic_time(:millisecond) + 60_000 + resolved_key = {"cached", 2, nil, true} + unresolved_key = {"cached", 2, nil, false} + other_key = {"cached", 3, nil, true} + + :ets.insert(:langfuse_prompt_cache, {resolved_key, prompt, expires_at}) + :ets.insert(:langfuse_prompt_cache, {unresolved_key, prompt, expires_at}) + :ets.insert(:langfuse_prompt_cache, {other_key, prompt, expires_at}) + + Prompt.invalidate("cached", version: 2) + + assert :ets.lookup(:langfuse_prompt_cache, resolved_key) == [] + assert :ets.lookup(:langfuse_prompt_cache, unresolved_key) == [] + assert :ets.lookup(:langfuse_prompt_cache, other_key) != [] + end + + test "invalidates labeled cache entries across resolve variants" do + try do + :ets.new(:langfuse_prompt_cache, [:set, :public, :named_table]) + rescue + ArgumentError -> :ok + end + + prompt = %Prompt{ + name: "cached", + version: 2, + type: :text, + prompt: "test", + labels: ["production"], + tags: [] + } + + expires_at = System.monotonic_time(:millisecond) + 60_000 + resolved_key = {"cached", nil, "production", true} + unresolved_key = {"cached", nil, "production", false} + other_key = {"cached", nil, "staging", true} + + :ets.insert(:langfuse_prompt_cache, {resolved_key, prompt, expires_at}) + :ets.insert(:langfuse_prompt_cache, {unresolved_key, prompt, expires_at}) + :ets.insert(:langfuse_prompt_cache, {other_key, prompt, expires_at}) + + Prompt.invalidate("cached", label: "production") + + assert :ets.lookup(:langfuse_prompt_cache, resolved_key) == [] + assert :ets.lookup(:langfuse_prompt_cache, unresolved_key) == [] + assert :ets.lookup(:langfuse_prompt_cache, other_key) != [] + end end describe "invalidate_all/0" do @@ -234,8 +298,8 @@ defmodule Langfuse.PromptTest do } expires_at = System.monotonic_time(:millisecond) + 60_000 - :ets.insert(:langfuse_prompt_cache, {{"test", nil, nil}, prompt, expires_at}) - :ets.insert(:langfuse_prompt_cache, {{"test", 2, nil}, prompt, expires_at}) + :ets.insert(:langfuse_prompt_cache, {{"test", nil, nil, true}, prompt, expires_at}) + :ets.insert(:langfuse_prompt_cache, {{"test", 2, nil, false}, prompt, expires_at}) Prompt.invalidate_all() @@ -294,9 +358,7 @@ defmodule Langfuse.PromptTest do end test "fetch/2 fetches prompt from API", %{bypass: bypass} do - Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts", fn conn -> - assert conn.query_string =~ "name=test-prompt" - + Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts/test-prompt", fn conn -> conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.resp( @@ -324,8 +386,7 @@ defmodule Langfuse.PromptTest do end test "fetch/2 with version option", %{bypass: bypass} do - Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts", fn conn -> - assert conn.query_string =~ "name=test-prompt" + Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts/test-prompt", fn conn -> assert conn.query_string =~ "version=3" conn @@ -341,8 +402,7 @@ defmodule Langfuse.PromptTest do end test "fetch/2 with label option", %{bypass: bypass} do - Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts", fn conn -> - assert conn.query_string =~ "name=test-prompt" + Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts/test-prompt", fn conn -> assert conn.query_string =~ "label=staging" conn @@ -358,7 +418,7 @@ defmodule Langfuse.PromptTest do end test "fetch/2 returns not_found for 404", %{bypass: bypass} do - Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts", fn conn -> + Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts/nonexistent", fn conn -> conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.resp(404, Jason.encode!(%{error: "Prompt not found"})) @@ -368,7 +428,7 @@ defmodule Langfuse.PromptTest do end test "fetch/2 parses chat type prompts", %{bypass: bypass} do - Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts", fn conn -> + Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts/chat-prompt", fn conn -> conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.resp( @@ -388,7 +448,7 @@ defmodule Langfuse.PromptTest do end test "get/2 caches prompts", %{bypass: bypass} do - Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts", fn conn -> + Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts/cached-prompt", fn conn -> conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.resp( @@ -403,8 +463,51 @@ defmodule Langfuse.PromptTest do assert prompt1.name == prompt2.name end + test "get/2 treats default resolve and explicit true as the same cache entry", %{ + bypass: bypass + } do + Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts/resolved-prompt", fn conn -> + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.resp( + 200, + Jason.encode!(%{name: "resolved-prompt", version: 1, type: "text", prompt: "cached"}) + ) + end) + + assert {:ok, prompt1} = Prompt.get("resolved-prompt") + assert {:ok, prompt2} = Prompt.get("resolved-prompt", resolve: true) + + assert prompt1 == prompt2 + end + + test "get/2 keeps unresolved prompts in a separate cache entry", %{bypass: bypass} do + Bypass.expect(bypass, "GET", "/api/public/v2/prompts/raw-prompt", fn conn -> + assert conn.query_string in ["", "resolve=false"] + + body = + case conn.query_string do + "resolve=false" -> + %{name: "raw-prompt", version: 1, type: "text", prompt: "raw {{dep}}"} + + _ -> + %{name: "raw-prompt", version: 1, type: "text", prompt: "resolved value"} + end + + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.resp(200, Jason.encode!(body)) + end) + + assert {:ok, resolved_prompt} = Prompt.get("raw-prompt") + assert {:ok, raw_prompt} = Prompt.get("raw-prompt", resolve: false) + + assert resolved_prompt.prompt == "resolved value" + assert raw_prompt.prompt == "raw {{dep}}" + end + test "get/2 with fallback prompt struct on error", %{bypass: bypass} do - Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts", fn conn -> + Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts/failing-prompt", fn conn -> Plug.Conn.resp(conn, 500, Jason.encode!(%{error: "Server error"})) end) @@ -424,7 +527,7 @@ defmodule Langfuse.PromptTest do end test "get/2 with fallback template string on error", %{bypass: bypass} do - Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts", fn conn -> + Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts/failing-prompt", fn conn -> Plug.Conn.resp(conn, 500, Jason.encode!(%{error: "Server error"})) end) @@ -436,7 +539,7 @@ defmodule Langfuse.PromptTest do end test "get/2 with fallback chat messages on error", %{bypass: bypass} do - Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts", fn conn -> + Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts/failing-prompt", fn conn -> Plug.Conn.resp(conn, 500, Jason.encode!(%{error: "Server error"})) end) @@ -448,7 +551,7 @@ defmodule Langfuse.PromptTest do end test "get/2 returns error without fallback", %{bypass: bypass} do - Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts", fn conn -> + Bypass.expect_once(bypass, "GET", "/api/public/v2/prompts/missing-prompt", fn conn -> Plug.Conn.resp(conn, 404, Jason.encode!(%{error: "Not found"})) end) @@ -456,7 +559,7 @@ defmodule Langfuse.PromptTest do end test "get/2 respects cache_ttl option", %{bypass: bypass} do - Bypass.expect(bypass, "GET", "/api/public/v2/prompts", fn conn -> + Bypass.expect(bypass, "GET", "/api/public/v2/prompts/ttl-test", fn conn -> conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.resp(