diff --git a/config/config.exs b/config/config.exs index 6d6efbc6..946cf71d 100644 --- a/config/config.exs +++ b/config/config.exs @@ -66,6 +66,8 @@ config :music_library, Wikipedia, user_agent: user_agent config :music_library, BraveSearch, api_key: "change me", user_agent: user_agent +config :music_library, OpenAI, api_key: "change me" + # Configure esbuild (the version is required) config :esbuild, version: "0.27.3", diff --git a/config/test.exs b/config/test.exs index c7ec57ff..e48fc5d4 100644 --- a/config/test.exs +++ b/config/test.exs @@ -78,6 +78,13 @@ config :music_library, BraveSearch, max_retries: 0 ] +config :music_library, OpenAI, + api_key: "test_key", + req_options: [ + plug: {Req.Test, OpenAI.API}, + max_retries: 0 + ] + config :phoenix_test, :endpoint, MusicLibraryWeb.Endpoint config :music_library, Oban, testing: :manual diff --git a/lib/open_ai.ex b/lib/open_ai.ex index 28acdad6..0be81511 100644 --- a/lib/open_ai.ex +++ b/lib/open_ai.ex @@ -2,7 +2,7 @@ defmodule OpenAI do alias OpenAI.API def gpt(completion) do - API.gpt(completion, api_key()) + API.gpt(completion, config()) end def chat_stream(messages, opts \\ []) when is_list(messages) do @@ -11,18 +11,15 @@ defmodule OpenAI do instructions = Keyword.get(opts, :instructions, "") on_chunk = Keyword.fetch!(opts, :on_chunk) - case API.chat_stream(messages, instructions, model, temperature, api_key(), on_chunk) do + case API.chat_stream(messages, instructions, model, temperature, config(), on_chunk) do :ok -> :ok {:error, _reason} = error -> error end end def embeddings(text) do - API.get_embeddings(text, api_key()) + API.get_embeddings(text, config()) end - defp api_key do - Application.get_env(:music_library, __MODULE__) - |> Keyword.fetch!(:api_key) - end + defp config, do: OpenAI.Config.resolve(:music_library) end diff --git a/lib/open_ai/api.ex b/lib/open_ai/api.ex index 461864ed..a079e04a 100644 --- a/lib/open_ai/api.ex +++ b/lib/open_ai/api.ex @@ -1,7 +1,12 @@ defmodule OpenAI.API do - def gpt(completion, api_key) do + require Logger + + def gpt(completion, config) do resp = - Req.post!("https://api.openai.com/v1/chat/completions", + config + |> new_request() + |> Req.merge( + url: "/v1/chat/completions", receive_timeout: 10_000, connect_options: [timeout: 2_500], json: %{ @@ -9,9 +14,9 @@ defmodule OpenAI.API do messages: [Map.take(completion, [:content, :role])], response_format: %{type: "json_object"}, temperature: completion.temperature - }, - auth: {:bearer, api_key} + } ) + |> Req.post!() if resp.status in 200..299 do content = get_in(resp.body, ["choices", Access.at(0), "message", "content"]) @@ -21,8 +26,11 @@ defmodule OpenAI.API do end end - def chat_stream(messages, instructions, model, temperature, api_key, cb) do - case Req.post("https://api.openai.com/v1/responses", + def chat_stream(messages, instructions, model, temperature, config, cb) do + case config + |> new_request() + |> Req.merge( + url: "/v1/responses", receive_timeout: 60_000, connect_options: [timeout: 5_000], json: %{ @@ -33,7 +41,6 @@ defmodule OpenAI.API do stream: true, temperature: temperature }, - auth: {:bearer, api_key}, into: fn {:data, data}, {req, resp} -> buffer = Req.Request.get_private(req, :sse_buffer, "") {events, buffer} = ServerSentEvents.parse(buffer <> data) @@ -45,22 +52,26 @@ defmodule OpenAI.API do {:cont, {req, resp}} end - ) do + ) + |> Req.post() do {:ok, %{status: status}} when status in 200..299 -> :ok {:ok, %{body: body}} -> {:error, "OpenAI API error: #{inspect(body)}"} {:error, exception} -> {:error, "Connection error: #{Exception.message(exception)}"} end end - def get_embeddings(text, api_key) do + def get_embeddings(text, config) do resp = - Req.post!("https://api.openai.com/v1/embeddings", + config + |> new_request() + |> Req.merge( + url: "/v1/embeddings", json: %{ input: text, model: "text-embedding-3-small" - }, - auth: {:bearer, api_key} + } ) + |> Req.post!() if resp.status == 200 do embeddings = get_in(resp.body, ["data", Access.at(0), "embedding"]) @@ -70,10 +81,37 @@ defmodule OpenAI.API do end end + defp new_request(config) do + Req.new( + base_url: "https://api.openai.com", + auth: {:bearer, config.api_key} + ) + |> Req.Request.merge_options(config.req_options) + |> Req.Request.append_request_steps(log_attempt: &log_attempt/1) + |> Req.Request.append_response_steps(log_error: &log_error/1) + end + defp decode_responses_event(json, cb) do case JSON.decode!(json) do %{"type" => "response.output_text.delta", "delta" => delta} -> cb.(delta) _other -> :ok end end + + defp log_attempt(request) do + url = URI.to_string(request.url) + Logger.debug("Fetching data from #{url}") + request + end + + defp log_error({request, response}) do + if response.status in 400..499 or response.status in 500..599 do + Logger.error(fn -> + url = URI.to_string(request.url) + "Failed to fetch data from #{url}, reason: #{inspect(response.body)}" + end) + end + + {request, response} + end end diff --git a/lib/open_ai/config.ex b/lib/open_ai/config.ex new file mode 100644 index 00000000..95c4d4ee --- /dev/null +++ b/lib/open_ai/config.ex @@ -0,0 +1,32 @@ +defmodule OpenAI.Config do + @type t :: %__MODULE__{ + api_key: String.t(), + req_options: Keyword.t() + } + + @enforce_keys [:api_key] + defstruct api_key: "", + req_options: [] + + @schema NimbleOptions.new!( + api_key: [ + type: :string, + required: true + ], + req_options: [ + type: :keyword_list, + required: false, + default: [] + ] + ) + + @doc NimbleOptions.docs(@schema) + @spec resolve(Application.app()) :: t | no_return + def resolve(otp_app) do + app_config = + Application.get_env(otp_app, OpenAI) + |> NimbleOptions.validate!(@schema) + + struct(__MODULE__, app_config) + end +end diff --git a/test/open_ai/api_test.exs b/test/open_ai/api_test.exs new file mode 100644 index 00000000..801f14a6 --- /dev/null +++ b/test/open_ai/api_test.exs @@ -0,0 +1,116 @@ +defmodule OpenAI.APITest do + use ExUnit.Case, async: true + + alias OpenAI.API + + @config %OpenAI.Config{ + api_key: "test_key", + req_options: [plug: {Req.Test, __MODULE__}, max_retries: 0] + } + + describe "gpt/2" do + test "returns parsed JSON on success" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.request_path == "/v1/chat/completions" + + Req.Test.json(conn, %{ + "choices" => [ + %{"message" => %{"content" => ~s({"result": "hello"})}} + ] + }) + end) + + completion = %{model: "gpt-4.1-mini", content: "test", role: "user", temperature: 0.5} + assert {:ok, %{"result" => "hello"}} = API.gpt(completion, @config) + end + + @tag :capture_log + test "returns error on non-2xx response" do + Req.Test.stub(__MODULE__, fn conn -> + conn + |> Plug.Conn.put_status(429) + |> Req.Test.json(%{"error" => "rate limited"}) + end) + + completion = %{model: "gpt-4.1-mini", content: "test", role: "user", temperature: 0.5} + assert {:error, _} = API.gpt(completion, @config) + end + end + + describe "get_embeddings/2" do + test "returns embedding vector on success" do + embedding = [0.1, 0.2, 0.3] + + Req.Test.stub(__MODULE__, fn conn -> + assert conn.request_path == "/v1/embeddings" + + Req.Test.json(conn, %{ + "data" => [%{"embedding" => embedding}] + }) + end) + + assert {:ok, ^embedding} = API.get_embeddings("test text", @config) + end + + @tag :capture_log + test "returns error on non-200 response" do + Req.Test.stub(__MODULE__, fn conn -> + conn + |> Plug.Conn.put_status(500) + |> Req.Test.json(%{"error" => "internal error"}) + end) + + assert {:error, _} = API.get_embeddings("test text", @config) + end + end + + describe "chat_stream/6" do + test "streams text deltas via callback" do + test_pid = self() + + Req.Test.stub(__MODULE__, fn conn -> + assert conn.request_path == "/v1/responses" + + body = + "data: #{JSON.encode!(%{"type" => "response.output_text.delta", "delta" => "Hello"})}\n\n" <> + "data: #{JSON.encode!(%{"type" => "response.output_text.delta", "delta" => " world"})}\n\n" <> + "data: #{JSON.encode!(%{"type" => "response.completed"})}\n\n" + + conn + |> Plug.Conn.put_resp_content_type("text/event-stream") + |> Plug.Conn.send_resp(200, body) + end) + + cb = fn chunk -> send(test_pid, {:chunk, chunk}) end + + assert :ok = API.chat_stream([], "instructions", "gpt-4.1", 0.7, @config, cb) + assert_received {:chunk, "Hello"} + assert_received {:chunk, " world"} + end + + @tag :capture_log + test "returns error on non-2xx response" do + Req.Test.stub(__MODULE__, fn conn -> + conn + |> Plug.Conn.put_status(500) + |> Req.Test.json(%{"error" => "server error"}) + end) + + cb = fn _chunk -> :ok end + + assert {:error, "OpenAI API error:" <> _} = + API.chat_stream([], "instructions", "gpt-4.1", 0.7, @config, cb) + end + + test "returns error on connection failure" do + Req.Test.stub(__MODULE__, fn conn -> + Req.Test.transport_error(conn, :timeout) + end) + + cb = fn _chunk -> :ok end + + assert {:error, "Connection error:" <> _} = + API.chat_stream([], "instructions", "gpt-4.1", 0.7, @config, cb) + end + end +end diff --git a/test/open_ai_test.exs b/test/open_ai_test.exs new file mode 100644 index 00000000..a55a6ca6 --- /dev/null +++ b/test/open_ai_test.exs @@ -0,0 +1,62 @@ +defmodule OpenAITest do + use ExUnit.Case, async: true + + describe "gpt/1" do + test "delegates to API with resolved config" do + Req.Test.stub(OpenAI.API, fn conn -> + assert conn.request_path == "/v1/chat/completions" + + Req.Test.json(conn, %{ + "choices" => [ + %{"message" => %{"content" => ~s({"answer": "42"})}} + ] + }) + end) + + completion = %{model: "gpt-4.1-mini", content: "test", role: "user", temperature: 0.5} + assert {:ok, %{"answer" => "42"}} = OpenAI.gpt(completion) + end + end + + describe "embeddings/1" do + test "delegates to API with resolved config" do + embedding = [0.1, 0.2, 0.3] + + Req.Test.stub(OpenAI.API, fn conn -> + assert conn.request_path == "/v1/embeddings" + + Req.Test.json(conn, %{ + "data" => [%{"embedding" => embedding}] + }) + end) + + assert {:ok, ^embedding} = OpenAI.embeddings("test text") + end + end + + describe "chat_stream/2" do + test "streams chunks via callback" do + test_pid = self() + + Req.Test.stub(OpenAI.API, fn conn -> + assert conn.request_path == "/v1/responses" + + body = + "data: #{JSON.encode!(%{"type" => "response.output_text.delta", "delta" => "Hi"})}\n\n" <> + "data: #{JSON.encode!(%{"type" => "response.completed"})}\n\n" + + conn + |> Plug.Conn.put_resp_content_type("text/event-stream") + |> Plug.Conn.send_resp(200, body) + end) + + assert :ok = + OpenAI.chat_stream( + [%{role: "user", content: "hello"}], + on_chunk: fn chunk -> send(test_pid, {:chunk, chunk}) end + ) + + assert_received {:chunk, "Hi"} + end + end +end