defmodule OpenAgents.Inference.CreditTest do
@moduledoc """
The account's inference money.
Two facts are proven here because both were false before: that signing in
raises the allowance, and that what a thread spends comes out of the
account's credit rather than out of a per-thread figure nothing adds up.
"""
use OpenAgents.DataCase, async: false
import Ecto.Query
import OpenAgentsWeb.ConnCase, only: [github_user: 1]
alias OpenAgents.Accounts.User
alias OpenAgents.Conversations
alias OpenAgents.Conversations.Visitor
alias OpenAgents.DataRights
alias OpenAgents.Inference
alias OpenAgents.Inference.Credit
alias OpenAgents.Inference.Models
alias OpenAgents.Repo
alias OpenAgents.Threads
alias OpenAgents.UnpricedLane
defp account(key) do
key |> github_user() |> Conversations.ensure_owner_visitor()
end
defp visitor(key) do
{:ok, conversation} = Conversations.ensure_conversation("credit-#{key}")
conversation.visitor_id
end
# Cost is priced from tokens by `OpenAgents.Inference`, never taken from a
# caller, so spend is stated here in the output tokens that price to it.
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
# A grant names exactly one fence, so spend is recorded through a real
# thread's grant rather than a fenceless one the changeset would refuse.
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 "signing in raises the allowance to the account credit" do
assert Credit.allowance(account("credit-signed-in").id) ==
Application.fetch_env!(:openagents, :account_credit_microusd)
end
# The allowance moved off the config constant and onto the account, so that
# "a new account is granted $20" could stop meaning "every account is now
# capped at $20". These three tests are what that distinction rests on.
describe "the allowance is the account's own" do
test "a new account is created holding the configured new-account grant" do
user = github_user("credit-new-account")
assert user.credit_allowance_microusd == Credit.new_account_allowance()
assert Credit.allowance(Conversations.ensure_owner_visitor(user).id) ==
Credit.new_account_allowance()
end
test "an account holding more than the current grant keeps it" do
user = github_user("credit-grandfathered")
owner = Conversations.ensure_owner_visitor(user)
# What an account created before the grant was lowered looks like. The
# migration writes exactly this figure across every row that already
# existed; here it is set directly so the read is what is under test.
{1, nil} =
Repo.update_all(
from(u in User, where: u.id == ^user.id),
set: [credit_allowance_microusd: 100_000_000]
)
assert Credit.allowance(owner.id) == 100_000_000
refute Credit.allowance(owner.id) == Credit.new_account_allowance()
assert Credit.remaining(owner.id) == 100_000_000
end
test "signing in again does not re-grant the credit" do
user = github_user("credit-returning")
owner = Conversations.ensure_owner_visitor(user)
{1, nil} =
Repo.update_all(
from(u in User, where: u.id == ^user.id),
set: [credit_allowance_microusd: 3_000_000]
)
# `github_user/1` is the upsert the OAuth callback runs, so this is a
# second sign-in by the same GitHub identity. It must not hand the
# account the new-account figure again.
same_user = github_user("credit-returning")
assert same_user.id == user.id
assert same_user.credit_allowance_microusd == 3_000_000
assert Credit.allowance(owner.id) == 3_000_000
end
end
# One GitHub identity holds one credited account: `users.github_id` carries a
# unique index, so a second signup on the same identity is the same row. The
# hole that leaves is deletion — `DataRights.delete/3` erases the visitor root
# the account's grants hang off, and spend is summed from those grants. The
# user row survives (DATA-004), so the allowance survives with it; without
# this, everything the account had spent would come back.
describe "deleting product data does not refund spend" do
test "the allowance absorbs what the erased grants had metered" do
user = github_user("credit-deletion")
owner = Conversations.ensure_owner_visitor(user)
{:ok, _metered} =
Inference.record_usage(minted(owner.id), %{
"output_tokens" => output_tokens_costing(4_000_000)
})
granted = Credit.allowance(owner.id)
assert Credit.spent(owner.id) == 4_000_000
left = Credit.remaining(owner.id)
assert left == granted - 4_000_000
{:ok, conversation} = Conversations.ensure_conversation(user)
conversation_owner = Conversations.get_conversation_owner!(conversation)
assert {:ok, :deleted} = DataRights.delete(user, conversation_owner, conversation)
# A fresh visitor root, so the erased grants are gone and `spent/1` reads
# zero again. What is left has to be the same number it was.
next = Conversations.ensure_owner_visitor(Repo.get!(User, user.id))
refute next.id == conversation_owner.id
assert Credit.spent(next.id) == 0
assert Credit.allowance(next.id) == granted - 4_000_000
assert Credit.remaining(next.id) == left
end
test "an account that spent nothing keeps its whole allowance" do
user = github_user("credit-deletion-unspent")
granted = Credit.allowance(Conversations.ensure_owner_visitor(user).id)
{:ok, conversation} = Conversations.ensure_conversation(user)
conversation_owner = Conversations.get_conversation_owner!(conversation)
assert {:ok, :deleted} = DataRights.delete(user, conversation_owner, conversation)
next = Conversations.ensure_owner_visitor(Repo.get!(User, user.id))
assert Credit.allowance(next.id) == granted
end
end
test "a visitor that has not signed in holds the visitor credit" do
assert Credit.allowance(visitor("anonymous")) ==
Application.fetch_env!(:openagents, :visitor_credit_microusd)
end
test "an account that has spent nothing has its whole allowance left" do
owner = account("credit-unspent")
assert Credit.spent(owner.id) == 0
assert Credit.remaining(owner.id) == Credit.allowance(owner.id)
end
test "what a grant metered comes out of the account's remaining credit" do
owner = account("credit-metered")
{:ok, _metered} =
Inference.record_usage(minted(owner.id), %{
"output_tokens" => output_tokens_costing(250_000)
})
assert Credit.spent(owner.id) == 250_000
assert Credit.remaining(owner.id) == Credit.allowance(owner.id) - 250_000
end
test "remaining credit never reads as negative" do
visitor_id = visitor("overspent")
allowance = Credit.allowance(visitor_id)
{:ok, _metered} =
Inference.record_usage(minted(visitor_id), %{
"output_tokens" => output_tokens_costing(allowance * 2)
})
assert Credit.remaining(visitor_id) == 0
end
test "a thread is minted for what the account has left, not a fresh ceiling" do
owner = account("credit-thread")
{:ok, remaining} = Threads.ceilings(owner.id)
assert remaining.max_cost_microusd == Credit.remaining(owner.id)
# The account's credit is the only ceiling a thread gets. The configured
# per-thread cost cap was $2 and is now unset, so the cost ceiling is not a
# cap being raised to the remainder — it is the remainder, and nothing else
# bounds the thread.
assert is_nil(Threads.ceilings().max_cost_microusd)
assert is_nil(remaining.max_calls)
assert is_nil(remaining.max_total_tokens)
assert is_nil(remaining.ttl_seconds)
end
test "an account with nothing left is refused rather than minted a grant" do
visitor_id = visitor("exhausted")
{:ok, _metered} =
Inference.record_usage(minted(visitor_id), %{
"output_tokens" => output_tokens_costing(Credit.allowance(visitor_id))
})
assert Threads.ceilings(visitor_id) == {:error, :credit_exhausted}
end
# METER-001. A cost ceiling can only stop spend it can measure, so a lane
# with no declared rates draws nothing down. That is a consequence of not
# knowing the rates, and the account's read has to say so rather than report
# a balance that looks untouched.
describe "spend the deployment has no price for" do
test "an unpriced call draws nothing down, and the balance says so" do
owner = account("credit-unpriced")
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})
# `spent/1` is a floor, not a total, and the floor here is zero.
assert Credit.spent(owner.id) == 0
assert Credit.remaining(owner.id) == Credit.allowance(owner.id)
# What stops that reading as "this account has spent nothing".
assert Credit.unpriced_calls(owner.id) == 1
balance = Credit.balance(owner.id)
assert balance.spent_microusd == 0
assert balance.unpriced_calls == 1
refute balance.complete?
end
test "an account whose every call was priced reports a complete balance" do
owner = account("credit-complete")
{:ok, _metered} =
Inference.record_usage(minted(owner.id), %{
"output_tokens" => output_tokens_costing(100_000)
})
balance = Credit.balance(owner.id)
assert balance.spent_microusd == 100_000
assert balance.unpriced_calls == 0
assert balance.complete?
end
test "a grant that never bought anything is not counted as unpriced spend" do
owner = account("credit-idle")
unpriced = admit_unpriced_lane()
{:ok, thread} = Threads.open(%Visitor{id: owner.id}, "Mint and stop", model: unpriced)
{:ok, _fenced, _grant, _token} = Threads.mint_grant(thread)
assert Credit.unpriced_calls(owner.id) == 0
assert Credit.balance(owner.id).complete?
end
end
end