lib/providers/openai.ex

defmodule LangChain.Providers.OpenAI do
  @moduledoc """
  OpenAI results return a body that will contain:
   `'usage': {'prompt_tokens': 56, 'completion_tokens': 31, 'total_tokens': 87}`
  
   OpenAI Pricing
  Model	Prompt	Completion
  gpt-4
    8K context	$0.03 / 1K tokens	$0.06 / 1K tokens
    32K context	$0.06 / 1K tokens	$0.12 / 1K tokens
  
  gpt-3.5-turbo	$0.002 / 1K tokens
  
  instructGPT (only models you can fine tune)
  Ada $0.0004 / 1K tokens
  Babbage $0.0005 / 1K tokens
  Curie $0.0020 / 1K tokens
  Davinci $0.0200 / 1K tokens
  Fine-tuning:
  Ada	$0.0004 / 1K tokens	$0.0016 / 1K tokens
  Babbage	$0.0006 / 1K tokens	$0.0024 / 1K tokens
  Curie	$0.0030 / 1K tokens	$0.0120 / 1K tokens
  Davinci	$0.0300 / 1K tokens	$0.1200 / 1K tokens
  
  embeddings
  Ada	$0.0004 / 1K tokens
  """

  # need to update this to scrape from page
  @pricing_structure %{
    "gpt-4-8k" => %{
      dollars_per_token: 0.00003
    },
    "gpt-4-32k" => %{
      dollars_per_token: 0.00006
    },
    "gpt-3.5" => %{
      dollars_per_token: 0.000002
    },
    "ada" => %{
      dollars_per_token: 0.0004
    },
    "babbage" => %{
      dollars_per_token: 0.0005
    },
    "curie" => %{
      dollars_per_token: 0.0020
    },
    "davinci" => %{
      dollars_per_token: 0.0200
    },
    :fine_tuning => %{
      "ada" => %{
        dollars_per_token: 0.0004
      },
      "babbage" => %{
        dollars_per_token: 0.0006
      },
      "curie" => %{
        dollars_per_token: 0.0030
      },
      "davinci" => %{
        dollars_per_token: 0.0300
      }
    },
    :embedding => %{
      "ada" => %{
        dollars_per_token: 0.0004
      }
    }
  }

  # get most-similar entry from pricing structure
  def get_pricing_structure(model_name) do
    @pricing_structure
    |> Enum.map(fn {key, value} ->
      if is_binary(key) do
        {String.jaro_distance(model_name, key), key, value}
      end
    end)
    |> Enum.reject(&is_nil/1)
    |> Enum.max_by(fn {score, _, _} -> score end)
    |> case do
      {score, _key, value} when score > 0.7 -> value
      _ -> 0
    end
  end

  @doc """
  Used to report the price of a response from OpenAI
  Needs to implement callbacks to a master pricing tracker
  """
  def report_price(model, response) do
    try do
      total_tokens = response.usage.total_tokens
      pricing_structure = get_pricing_structure(response.model)

      total_price =
        (pricing_structure.dollars_per_token * total_tokens)
        |> :erlang.float_to_binary(decimals: 8)

      LangChain.Agents.TheAccountant.store(%{
        provider: :openai,
        model_name: model.model_name,
        total_price: total_price
      })

      # IO.puts("OpenAI #{total_tokens} tokens cost $#{total_price}")
    rescue
      error -> error
    end
  end
end

defmodule LangChain.Embedder.OpenAIProvider do
  @moduledoc """
  An OpenAI implementation of the LangChain.EmbedderProtocol.
  Use this for embedding your docs for openai models by specifying the
  model_name in your LLM.
  """

  defstruct model_name: "text-ada-001"

  defimpl LangChain.EmbedderProtocol do
    def embed_documents(provider, documents) do
      opts = []

      with {:ok, results} <-
             ExOpenAI.Embeddings.create_embedding(documents, provider.model_name, opts) do
        case results do
          %ExOpenAI.Components.CreateEmbeddingResponse{data: data} ->
            embeddings = Enum.map(data, fn %{embedding: embedding} -> embedding end)
            {:ok, embeddings}

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

    def embed_query(provider, query) do
      embed_documents(provider, [query])
    end
  end
end

defmodule LangChain.Providers.OpenAI.LanguageModel do
  @moduledoc """
  A module for interacting with OpenAI's main language models
  """

  defstruct provider: :openai,
            model_name: "gpt-3.5-turbo",
            max_tokens: 25,
            temperature: 0.1,
            n: 1

  defimpl LangChain.LanguageModelProtocol, for: LangChain.Providers.OpenAI.LanguageModel do
    alias ExOpenAI.Components.CreateCompletionResponse

    # these models require the prompt be presented as a 'chat'
    # or sequence of messages
    @chatmodels [
      "gpt-4",
      "gpt-4-0314",
      "gpt-4-32k",
      "gpt-4-32k-0314",
      "gpt-3.5-turbo",
      "gpt-3.5-turbo-0301"
    ]
    defp chat_model?(model_name) do
      model_name in @chatmodels
    end

    def ask(model, prompt) do
      # some models are conversational and others are single-prompt only,
      # this handles fixing it up so it works either way
      if chat_model?(model.model_name) do
        # prompt is either a string or a list of messages
        msgs =
          if is_binary(prompt) do
            [%{text: prompt, role: "user"}]
          else
            prompt
          end

        chat(model, msgs)
      else
        # prompt is either a string or a list of messages, needs to just
        # be a single string for this model
        msg =
          if is_binary(prompt) do
            prompt
          else
            prompt |> Enum.map_join("\n", & &1.text)
          end

        {:ok, response} =
          ExOpenAI.Completions.create_completion(
            model.model_name,
            prompt: msg,
            temperature: model.temperature,
            max_tokens: model.max_tokens
          )

        # extract_text is a list, call only returns the first text
        extract_text(response)
      end
    end

    defp extract_text(%CreateCompletionResponse{choices: [%{text: text} | _]}) do
      text
    end

    defp chat(model, msgs) do
      converted = chats_to_openai(msgs)

      case ExOpenAI.Chat.create_chat_completion(converted, model.model_name, n: model.n) do
        {:ok, response} ->
          LangChain.Providers.OpenAI.report_price(model, response)

          cond do
            # if it's a list just return the first 'text' field
            is_list(response) ->
              response
              |> List.first()
              |> Map.get(:text)

            # if it's a map it should have a choices.message field with the 'content' or 'text'
            is_map(response) ->
              response
              |> Map.get(:choices, %{})
              |> List.first()
              |> Map.get(:message, %{})
              |> Map.get(:content, "I could not understand the result I got back")

            true ->
              "Here is the response I got back: #{inspect(response)}"
          end

        {:error, error} ->
          "Model #{model.model_name}: I had an error processing.  This is the error message: #{inspect(error)}"
      end
    end

    # convert any list of chats to open ai format
    # [
    #   %{text: "hello", role: "user"},
    #   %{text: "hi"}
    # ] should be converted to
    # [
    #   %{content: "hello", role: "user"},
    #   %{content: "hi", role: "assistant"}
    # ]
    defp chats_to_openai(chats) do
      Enum.map(chats, fn chat ->
        case chat do
          %{role: role, text: text} ->
            %{content: text, role: role}

          %{text: text} ->
            %{content: text, role: "assistant"}

          %{content: content, role: role} ->
            %{content: content, role: role}

          _ ->
            %{}
        end
      end)
    end
  end
end