lib/open_ai_client.ex

defmodule OpenAiClient do
  @moduledoc """
  A client for the OpenAI API.

  This client supports all options provided by the `Req` library, as well as additional options:
  - :breaker - a circuit breaker module (default: `ExBreak`)
  - :openai_organization - the OpenAI organization ID
  """

  import Req.Request, only: [put_new_header: 3]

  @doc """
  Sends a POST request to the OpenAI API.

  ## Examples

      iex> OpenAiClient.post("https://api.openai.com/v1/chat/completions",
      ...> [json: %{model: "gpt-3.5-turbo", messages: [%{role: "system", content: "You are a helpful assistant."}, %{role: "user", content: "Who won the world series in 2020?"}]}])
      {:ok, %{"id" => "chatcmpl-3o4Bz2rawi6wk8c4L8QR4W7eFCDj", "object" => "chat.completion", "created" => 1646183485, "model" => "gpt-3.5-turbo", "choices" => [%{"message" => %{ "role" => "assistant", "content" => "The Los Angeles Dodgers won the World Series in 2020."}, "finish_reason" => "stop", "index" => 0}]}}

  """
  @spec post(String.t(), Keyword.t()) :: {:ok, map()} | {:error, any()}
  def post(url, options \\ []) do
    send_request(:post, url, options)
  end

  @doc """
  Sends a GET request to the OpenAI API.

  ## Examples

      iex> OpenAiClient.get("https://api.openai.com/v1/models")
      {:ok, %{"models" => [%{"id" => "gpt-3.5-turbo", "object" => "model", "created" => 1646183485, "name" => "gpt-3.5-turbo"}]}}

  """
  @spec get(String.t(), Keyword.t()) :: {:ok, map()} | {:error, any()}
  def get(url, options \\ []) do
    send_request(:get, url, options)
  end

  defp send_request(method, url, options) do
    breaker = Keyword.get(options, :breaker, ExBreak)
    options = Keyword.delete(options, :breaker)

    breaker.call(&__MODULE__.__do_request__/3, [method, url, options],
      threshold: 10,
      timeout_sec: 120,
      match_return: &breaker_response/1
    )
  end

  defp breaker_response(response) do
    case response do
      %{status: status} when status in 500..599 -> true
      %{status: 429} -> true
      _ -> false
    end
  end

  def __do_request__(method, url, options) do
    options = Keyword.put(options, :method, method)

    build_request(url, options)
    |> Req.request()
  end

  defp build_request(url, options) do
    openai_organization = options[:openai_organization] || default_organization()
    options = Keyword.delete(options, :openai_organization)

    options
    |> Keyword.put(:url, url)
    |> validate_options()
    |> remove_nil_values()
    |> Req.new()
    |> set_headers(openai_organization: openai_organization)
  end

  defp validate_options(options) do
    Keyword.validate!(options, [
      :url,
      :json,
      :method,
      retry_delay: nil,
      retry_log_level: nil,
      into: nil,
      retry: :transient,
      receive_timeout: default_receive_timeout(),
      base_url: default_base_url(),
      auth: {:bearer, default_api_key()}
    ])
  end

  defp remove_nil_values(options) do
    Enum.reject(options, fn {_key, value} -> value == nil end)
  end

  defp default_receive_timeout do
    Application.get_env(:open_ai_client, :receive_timeout)
  end

  defp default_base_url do
    Application.get_env(:open_ai_client, :base_url)
  end

  defp default_api_key do
    Application.get_env(:open_ai_client, :openai_api_key)
  end

  defp default_organization do
    Application.get_env(:open_ai_client, :openai_organization_id)
  end

  defp set_headers(%Req.Request{} = req, options) when is_list(options) do
    req
    |> put_new_header("openai-beta", "assistants=v1")
    |> then(
      &if options[:openai_organization],
        do: put_new_header(&1, "openai-organization", options[:openai_organization]),
        else: &1
    )
  end
end