Skip to main content

lib/agent_sea/embedder/open_ai.ex

defmodule AgentSea.Embedder.OpenAI do
  @moduledoc """
  OpenAI embeddings adapter (`POST /v1/embeddings`) over `Req` — a remote
  `AgentSea.Embedder` (no local model).

  Options: `:api_key` (defaults to `OPENAI_API_KEY`), `:model` (default
  `text-embedding-3-small`), `:base_url`, `:adapter` (a Req adapter for tests).
  `dimensions/0` reads `config :agentsea_embeddings, :openai_dimensions` (default
  1536 — must match the model).
  """

  @behaviour AgentSea.Embedder

  @base_url "https://api.openai.com"

  @impl true
  def embed(texts, opts) do
    body = %{model: opts[:model] || "text-embedding-3-small", input: texts}

    case Req.post(req(opts), url: "/v1/embeddings", json: body) do
      {:ok, %Req.Response{status: 200, body: %{"data" => data}}} ->
        # Preserve input order via the response index.
        vectors = data |> Enum.sort_by(& &1["index"]) |> Enum.map(& &1["embedding"])
        {:ok, vectors}

      {:ok, %Req.Response{status: status, body: body}} ->
        {:error, {:http_error, status, body}}

      {:error, reason} ->
        {:error, reason}
    end
  end

  @impl true
  def dimensions do
    Application.get_env(:agentsea_embeddings, :openai_dimensions, 1536)
  end

  defp req(opts) do
    api_key = opts[:api_key] || System.get_env("OPENAI_API_KEY")

    [
      base_url: opts[:base_url] || @base_url,
      headers: [{"authorization", "Bearer #{api_key || ""}"}]
    ]
    |> maybe_put(:adapter, opts[:adapter])
    |> Req.new()
  end

  defp maybe_put(kw, _key, nil), do: kw
  defp maybe_put(kw, key, value), do: Keyword.put(kw, key, value)
end