test/openagents_web/controllers/credit_controller_test.exs

main at 58e6347eeb72 · 4 KB

defmodule OpenAgentsWeb.CreditControllerTest do
  @moduledoc """
  `GET /api/v1/credit`: what the coder's status bar reads.

  The reason this endpoint exists, rather than a client keeping its own total,
  is that spend is the server's: it prices the call, and a second terminal on
  the same account spends the same money. The reason it carries `complete` is
  METER-001 — an unpriced call records no cost, so the remainder does not move,
  and a client that rendered the remainder alone would show a full balance
  beside a session that had run all day.
  """

  use OpenAgentsWeb.ConnCase, async: false

  import Ecto.Query

  alias OpenAgents.Accounts.User
  alias OpenAgents.Conversations
  alias OpenAgents.Conversations.Visitor
  alias OpenAgents.Inference
  alias OpenAgents.Inference.Credit
  alias OpenAgents.Inference.Models
  alias OpenAgents.Repo
  alias OpenAgents.Threads
  alias OpenAgents.UnpricedLane

  # `put_chat_api_token/2` mints the token for this account, so naming the key
  # the same way is how a test reaches the user behind the credential it sent.
  defp account(key), do: github_user("api-token-" <> key)

  defp output_tokens_costing(microusd) do
    div(microusd * 1_000_000, Models.default().pricing.output_per_million_tokens)
  end

  # `gpt-5.6-luna` was the shipped unpriced lane until it was withdrawn. What it
  # demonstrated is unchanged, so the lane is admitted for one test at a time.
  defp admit_unpriced_lane do
    previous = UnpricedLane.admit!()
    on_exit(fn -> UnpricedLane.restore(previous) end)
    UnpricedLane.id()
  end

  defp minted(visitor_id) do
    {:ok, thread} = Threads.open(%Visitor{id: visitor_id}, "spend some credit")
    {:ok, _fenced, grant, _token} = Threads.mint_grant(thread)
    grant
  end

  test "a new account reads its whole grant, with nothing unseen", %{conn: conn} do
    body =
      conn
      |> put_chat_api_token("credit-fresh")
      |> get(~p"/api/v1/credit")
      |> json_response(200)

    assert body["credit"]["allowance_microusd"] == Credit.new_account_allowance()
    assert body["credit"]["spent_microusd"] == 0
    assert body["credit"]["remaining_microusd"] == Credit.new_account_allowance()
    assert body["credit"]["unpriced_calls"] == 0
    assert body["credit"]["complete"] == true
  end

  test "priced spend comes off the remainder", %{conn: conn} do
    owner = "credit-spent" |> account() |> Conversations.ensure_owner_visitor()

    {:ok, _metered} =
      Inference.record_usage(minted(owner.id), %{
        "output_tokens" => output_tokens_costing(1_600_000)
      })

    body =
      conn
      |> put_chat_api_token("credit-spent")
      |> get(~p"/api/v1/credit")
      |> json_response(200)

    assert body["credit"]["spent_microusd"] == 1_600_000

    assert body["credit"]["remaining_microusd"] ==
             Credit.new_account_allowance() - 1_600_000

    assert body["credit"]["complete"] == true
  end

  # No lane this deployment admits is unpriced any more, but a call the
  # gateway's fallback chain answers with a model outside the catalog still
  # records no cost. The figure does not move, and the response has to say why
  # rather than leave the client to discover it.
  test "an unpriced call leaves the remainder still and says so", %{conn: conn} do
    owner = "credit-unpriced" |> account() |> Conversations.ensure_owner_visitor()
    unpriced = admit_unpriced_lane()

    {:ok, thread} = Threads.open(%Visitor{id: owner.id}, "Run the unpriced lane", model: unpriced)
    {:ok, _fenced, grant, _token} = Threads.mint_grant(thread)
    {:ok, _metered} = Inference.record_usage(grant, %{"output_tokens" => 500_000})

    body =
      conn
      |> put_chat_api_token("credit-unpriced")
      |> get(~p"/api/v1/credit")
      |> json_response(200)

    assert body["credit"]["spent_microusd"] == 0
    assert body["credit"]["remaining_microusd"] == Credit.new_account_allowance()
    assert body["credit"]["unpriced_calls"] == 1
    assert body["credit"]["complete"] == false
  end

  test "an account holding a grandfathered allowance reads its own figure", %{conn: conn} do
    user = account("credit-grandfathered-api")

    {1, nil} =
      Repo.update_all(
        from(u in User, where: u.id == ^user.id),
        set: [credit_allowance_microusd: 100_000_000]
      )

    body =
      conn
      |> put_chat_api_token("credit-grandfathered-api")
      |> get(~p"/api/v1/credit")
      |> json_response(200)

    assert body["credit"]["allowance_microusd"] == 100_000_000
    assert body["credit"]["remaining_microusd"] == 100_000_000
  end

  test "an anonymous caller is refused rather than shown an account", %{conn: conn} do
    assert conn |> get(~p"/api/v1/credit") |> json_response(401)
  end
end