Skip to main content

lib/baton/pricing.ex

defmodule Baton.Pricing do
  @moduledoc """
  Behaviour for computing the cost of an LLM call.

  Hardcoded prices go stale fast, so Baton does not ship authoritative
  prices. Instead it defines this behaviour and calls whichever module the host
  configures:

      config :baton, pricing: MyApp.LLMPricing

  Your implementation receives a usage map and returns a cost as `Decimal.t()`,
  or `nil` if the model is unknown (cost is then recorded as nil rather than a
  wrong number).

      defmodule MyApp.LLMPricing do
        @behaviour Baton.Pricing

        @impl true
        def cost(%{model: "claude-sonnet-4-" <> _, input_tokens: i, output_tokens: o}) do
          Decimal.add(
            Decimal.mult(Decimal.new(i), Decimal.from_float(3.0 / 1_000_000)),
            Decimal.mult(Decimal.new(o), Decimal.from_float(15.0 / 1_000_000))
          )
        end

        def cost(_usage), do: nil
      end

  `Baton.Pricing.Default` is provided as a starting point and for tests, but
  you should supply your own and keep it current.
  """

  @type usage :: %{
          required(:model) => String.t() | nil,
          optional(:input_tokens) => non_neg_integer(),
          optional(:output_tokens) => non_neg_integer(),
          optional(:cache_read_tokens) => non_neg_integer(),
          optional(:cache_write_tokens) => non_neg_integer()
        }

  @doc "Return the cost in USD for a usage map, or nil if it can't be priced."
  @callback cost(usage()) :: Decimal.t() | nil

  @doc """
  Compute cost via the configured pricing module.

  Returns `{:ok, Decimal.t()}`, or `{:error, :unpriced}` when the module
  returns nil (unknown model, missing data, etc.).
  """
  @spec cost(usage()) :: {:ok, Decimal.t()} | {:error, :unpriced}
  def cost(usage) do
    case Baton.Config.pricing().cost(usage) do
      %Decimal{} = d -> {:ok, d}
      nil -> {:error, :unpriced}
    end
  end
end

defmodule Baton.Pricing.Default do
  @moduledoc """
  A reference `Baton.Pricing` implementation with a small built-in price
  table (USD per million tokens).

  **Verify these against current provider pricing before relying on them for
  billing.** They are intended as a starting point and for tests. Override by
  configuring your own module: `config :baton, pricing: MyApp.LLMPricing`.
  """

  @behaviour Baton.Pricing

  # USD per million tokens. Update or replace with your own module.
  @prices %{
    "claude-opus-4-20250514" => %{input: 15.0, output: 75.0, cache_read: 1.5, cache_write: 18.75},
    "claude-sonnet-4-20250514" => %{input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75},
    "claude-haiku-4-20250514" => %{input: 0.8, output: 4.0, cache_read: 0.08, cache_write: 1.0},
    "gpt-4o" => %{input: 2.5, output: 10.0, cache_read: 1.25, cache_write: nil},
    "gpt-4o-mini" => %{input: 0.15, output: 0.6, cache_read: 0.075, cache_write: nil},
    "o3" => %{input: 10.0, output: 40.0, cache_read: 2.5, cache_write: nil},
    "o4-mini" => %{input: 1.1, output: 4.4, cache_read: 0.275, cache_write: nil}
  }

  @impl Baton.Pricing
  def cost(%{model: model} = usage) when is_binary(model) do
    case Map.get(@prices, model) do
      nil ->
        nil

      rates ->
        [
          line(usage, :input_tokens, rates.input),
          line(usage, :output_tokens, rates.output),
          line(usage, :cache_read_tokens, rates[:cache_read]),
          line(usage, :cache_write_tokens, rates[:cache_write])
        ]
        |> Enum.reject(&is_nil/1)
        |> Enum.reduce(Decimal.new(0), &Decimal.add/2)
    end
  end

  def cost(_usage), do: nil

  @doc "The built-in price table (for display/inspection)."
  def prices, do: @prices

  defp line(usage, key, rate) do
    count = Map.get(usage, key, 0) || 0

    cond do
      count == 0 -> Decimal.new(0)
      is_nil(rate) -> nil
      true -> Decimal.mult(Decimal.new(count), Decimal.from_float(rate / 1_000_000))
    end
  end
end