lib/openagents/voice/usage.ex

58e6347eeb72 · 5 KB

defmodule OpenAgents.Voice.Usage do
  @moduledoc "Whitelisted Realtime token accounting and versioned conservative cost estimates."

  @pricing_id "openai.gpt-realtime-2.1.2026-08-16"
  @numeric_fields ~w(input_tokens output_tokens total_tokens input_text_tokens input_audio_tokens input_image_tokens input_cached_tokens input_cached_text_tokens input_cached_audio_tokens input_cached_image_tokens input_unclassified_tokens output_text_tokens output_audio_tokens output_reasoning_tokens output_unclassified_tokens estimated_cost_microusd)

  @spec normalize_provider(map() | nil) :: map() | nil
  def normalize_provider(usage) when is_map(usage) do
    if Enum.any?(~w(input_tokens output_tokens total_tokens), &valid_integer?(usage[&1])) do
      input = map(usage["input_token_details"])
      cached = map(input["cached_tokens_details"])
      output = map(usage["output_token_details"])

      %{
        "input_tokens" => integer(usage["input_tokens"]),
        "output_tokens" => integer(usage["output_tokens"]),
        "total_tokens" => integer(usage["total_tokens"]),
        "input_text_tokens" => integer(input["text_tokens"]),
        "input_audio_tokens" => integer(input["audio_tokens"]),
        "input_image_tokens" => integer(input["image_tokens"]),
        "input_cached_tokens" => integer(input["cached_tokens"]),
        "input_cached_text_tokens" => integer(cached["text_tokens"]),
        "input_cached_audio_tokens" => integer(cached["audio_tokens"]),
        "input_cached_image_tokens" => integer(cached["image_tokens"]),
        "output_text_tokens" => integer(output["text_tokens"]),
        "output_audio_tokens" => integer(output["audio_tokens"]),
        "output_reasoning_tokens" => integer(output["reasoning_tokens"])
      }
    else
      nil
    end
  end

  def normalize_provider(_usage), do: nil

  @spec price(map(), String.t()) :: map()
  def price(usage, "gpt-realtime-2.1") when is_map(usage) do
    usage = Map.merge(Map.new(@numeric_fields, &{&1, 0}), usage)

    input_classified =
      integer(usage["input_text_tokens"]) + integer(usage["input_audio_tokens"]) +
        integer(usage["input_image_tokens"])

    output_classified =
      integer(usage["output_text_tokens"]) + integer(usage["output_audio_tokens"])

    input_unclassified = max(integer(usage["input_tokens"]) - input_classified, 0)
    output_unclassified = max(integer(usage["output_tokens"]) - output_classified, 0)

    cost =
      noncached(usage, "input_text_tokens", "input_cached_text_tokens") * 4 +
        noncached(usage, "input_audio_tokens", "input_cached_audio_tokens") * 32 +
        noncached(usage, "input_image_tokens", "input_cached_image_tokens") * 5 +
        integer(usage["input_cached_text_tokens"]) * 0.4 +
        integer(usage["input_cached_audio_tokens"]) * 0.4 +
        integer(usage["input_cached_image_tokens"]) * 0.5 +
        integer(usage["output_text_tokens"]) * 24 +
        integer(usage["output_audio_tokens"]) * 64 +
        input_unclassified * 32 +
        output_unclassified * 64

    usage
    |> Map.put("schema", "sarah.voice_usage.v1")
    |> Map.put("pricing_id", @pricing_id)
    |> Map.put("input_unclassified_tokens", input_unclassified)
    |> Map.put("output_unclassified_tokens", output_unclassified)
    |> Map.put("estimated_cost_microusd", ceil(cost))
  end

  def price(usage, _unpriced_model) when is_map(usage) do
    usage = Map.merge(Map.new(@numeric_fields, &{&1, 0}), usage)

    usage
    |> Map.put("schema", "sarah.voice_usage.v1")
    |> Map.put("pricing_id", "unpriced")
    |> Map.put("input_unclassified_tokens", usage["input_tokens"] || 0)
    |> Map.put("output_unclassified_tokens", usage["output_tokens"] || 0)
    |> Map.put("estimated_cost_microusd", 0)
  end

  @spec merge(map(), map()) :: map()
  def merge(existing, reported) when is_map(existing) and is_map(reported) do
    totals =
      Map.new(@numeric_fields, fn key ->
        {key, integer(existing[key]) + integer(reported[key])}
      end)

    totals
    |> Map.put("schema", "sarah.voice_usage.v1")
    |> Map.put("pricing_id", reported["pricing_id"] || existing["pricing_id"] || "unpriced")
  end

  @spec over_budget?(map()) :: boolean()
  def over_budget?(usage) when is_map(usage) do
    integer(usage["total_tokens"]) >=
      Application.fetch_env!(:openagents, :voice_maximum_session_tokens) or
      integer(usage["estimated_cost_microusd"]) >=
        Application.fetch_env!(:openagents, :voice_maximum_estimated_cost_microusd)
  end

  @doc """
  True once the session has consumed 80% of either budget ceiling, so the
  runtime can warn the model to wrap up before `over_budget?/1` ends the call.
  """
  @spec near_budget?(map()) :: boolean()
  def near_budget?(usage) when is_map(usage) do
    token_budget = Application.fetch_env!(:openagents, :voice_maximum_session_tokens)
    cost_budget = Application.fetch_env!(:openagents, :voice_maximum_estimated_cost_microusd)

    integer(usage["total_tokens"]) * 10 >= token_budget * 8 or
      integer(usage["estimated_cost_microusd"]) * 10 >= cost_budget * 8
  end

  defp noncached(usage, total_key, cached_key),
    do: max(integer(usage[total_key]) - integer(usage[cached_key]), 0)

  defp map(value) when is_map(value), do: value
  defp map(_value), do: %{}
  defp integer(value) when is_integer(value) and value >= 0, do: value
  defp integer(_value), do: 0
  defp valid_integer?(value), do: is_integer(value) and value >= 0
end