Grant a new account $20, and let it read what is left

c303a37ecae9 · AtlantisPleb · · parent e347f251b39b

Grant a new account $20, and let it read what is left

The inference allowance was a config constant, so it could answer one
question: what every signed-in account holds. Lowering it to $20 would
not have granted new users $20 — it would have re-priced every existing
account and put any that had metered more than $20 out of credit in the
same deploy.

So the allowance moves onto `users` as `credit_allowance_microusd`.
Config keeps the figure a new row is created with; the column is what
`Credit.allowance/1` reads. The migration adds it nullable, writes
100_000_000 across every row that already exists, and only then makes it
`NOT NULL` with the $20 default — the order is the point, and
`OpenAgents.Repo.CreditAllowanceBackfillTest` runs the migration down and
up over rows inserted beforehand rather than asserting that it works.

Spend is untouched: still summed from the grants' own `usage`, still no
second counter to disagree with a ledger.

Deletion had a hole, in a different place than expected. The user row
survives `DataRights.delete/3` by design (DATA-004), so a second signup
on the same GitHub identity is the same row and the same allowance — the
unique index holds. But the grants hang off the visitor root that
deletion removes, so `spent/1` read zero again afterwards and the account
recovered everything it had spent, once per deletion, indefinitely. The
allowance now absorbs the erased spend, so `allowance - spent` is the
same number after the deletion as before it: nothing left is lost, and
nothing spent comes back.

`GET /api/v1/credit` returns the allowance, the spend, the remainder, and
— because the coder's own lane is unpriced — the count of calls the
server could not price, with `complete: false` beside them. A client that
rendered the remainder alone would show a full balance to a session that
had run all day.

Also removes two unused private helpers in `install_script_test.exs` that
were failing the suite's `--warnings-as-errors` gate; they are unrelated
to this change and were dead before it.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 450 · 2026-08-26T15:36:10.273337Z

Changed files

  • modified config/config.exs
  • modified lib/openagents/accounts.ex
  • modified lib/openagents/accounts/user.ex
  • modified lib/openagents/data_rights.ex
  • modified lib/openagents/data_rights/export_inventory.ex
  • modified lib/openagents/inference/credit.ex
  • modified lib/openagents_web/api_route_authority.ex
  • modified lib/openagents_web/controllers/api_extension_controller.ex
  • added lib/openagents_web/controllers/credit_controller.ex
  • modified lib/openagents_web/router.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260826145554_grant_new_accounts_twenty_dollars_of_credit.exs
  • modified test/openagents/inference/credit_test.exs
  • added test/openagents/repo/credit_allowance_backfill_test.exs
  • modified test/openagents_web/controllers/api_extension_governance_test.exs
  • added test/openagents_web/controllers/credit_controller_test.exs
  • modified test/openagents_web/controllers/thread_controller_test.exs
  • modified test/openagents_web/install_script_test.exs

Diff

18 files changed, +686 -44

config/config.exs modified +9 -2

@@ -455,9 +455,16 @@ config :openagents,

455 455
  thread_grant_ttl_seconds: nil,
456 456
  # The inference credit an account draws its threads against. Signing in is
457 457
  # what raises it: a visitor holding only a browser key gets the same figure a
458
  # single thread used to get, and an account with a user behind it gets $100 to
458
  # single thread used to get, and an account with a user behind it gets $20 to
459 459
  # spend across every thread it opens.
460
  account_credit_microusd: 100_000_000,
460
  #
461
  # This figure is the default a *new* account is created with, not the
462
  # allowance every account holds. The allowance lives on `users` as
463
  # `credit_allowance_microusd`, so lowering this number re-prices the next
464
  # signup rather than every account that already exists — the accounts created
465
  # while it read $100 still hold $100. `OpenAgents.Inference.Credit` reads the
466
  # column; only account creation reads this.
467
  account_credit_microusd: 20_000_000,
461 468
  visitor_credit_microusd: 2_000_000,
462 469
  inference_input_price_microusd_per_ktoken: 1_250,
463 470
  inference_output_price_microusd_per_ktoken: 10_000,
lib/openagents/accounts.ex modified +10

@@ -4,6 +4,7 @@ defmodule OpenAgents.Accounts do

4 4
  import Ecto.Query
5 5
6 6
  alias OpenAgents.Accounts.{OAuthAttempt, TokenVault, User}
7
  alias OpenAgents.Inference.Credit
7 8
  alias OpenAgents.Repo
8 9
9 10
  @oauth_attempt_retention_seconds 86_400

@@ -22,6 +23,15 @@ defmodule OpenAgents.Accounts do

22 23
23 24
    %User{}
24 25
    |> User.github_changeset(attributes)
26
    # The credit a new account is granted, written once at creation. The
27
    # `on_conflict` replacement list below deliberately omits it, so a sign-in
28
    # by an account that already exists carries its own allowance forward
29
    # rather than being re-granted the current figure — which is what makes
30
    # "new accounts get $20" different from "every account now has $20".
31
    |> Ecto.Changeset.put_change(
32
      :credit_allowance_microusd,
33
      Credit.new_account_allowance()
34
    )
25 35
    |> Repo.insert(
26 36
      on_conflict:
27 37
        {:replace,
lib/openagents/accounts/user.ex modified +13

@@ -25,6 +25,18 @@ defmodule OpenAgents.Accounts.User do

25 25
    field :public_leaderboard_opted_out, :boolean, default: false
26 26
    field :browser_key_hash, :binary
27 27
28
    # The inference money this account holds, in microUSD. It is on the account
29
    # rather than in config because the two are not the same question: config
30
    # says what a new account is granted, and this says what this one holds.
31
    # An account created when the grant was $100 still holds $100 after the
32
    # grant became $20, and a later top-up is a write here rather than a new
33
    # subsystem.
34
    #
35
    # Only the allowance is here. Spend is still summed from the grants' own
36
    # `usage` by `OpenAgents.Inference.Credit.spent/1`, so this column can
37
    # never disagree with a spend counter — there is no spend counter.
38
    field :credit_allowance_microusd, :integer
39
28 40
    # The notification channel's address, and the proof its owner asked for it.
29 41
    # Nothing reads `notification_email` as a recipient on its own:
30 42
    # `OpenAgents.Notifications.EmailChannel.verified_address/1` is the one

@@ -73,6 +85,7 @@ defmodule OpenAgents.Accounts.User do

73 85
          github_token_rotated_at: DateTime.t() | nil,
74 86
          public_leaderboard_opted_out: boolean(),
75 87
          browser_key_hash: binary() | nil,
88
          credit_allowance_microusd: non_neg_integer(),
76 89
          notification_email: String.t() | nil,
77 90
          notification_email_verified_at: DateTime.t() | nil,
78 91
          notification_email_code_digest: binary() | nil,
lib/openagents/data_rights.ex modified +10

@@ -14,6 +14,7 @@ defmodule OpenAgents.DataRights do

14 14
    SourceMembership
15 15
  }
16 16
17
  alias OpenAgents.Inference.Credit
17 18
  alias OpenAgents.Memories.Memory
18 19
  alias OpenAgents.Memory.SemanticDerivativeReceipt
19 20
  alias OpenAgents.{Accounts, ApiTokens, Conversations, ProfileMemory, Repo}

@@ -162,6 +163,15 @@ defmodule OpenAgents.DataRights do

162 163
      {_deleted_memories, nil} =
163 164
        Repo.delete_all(from(memory in Memory, where: memory.user_id == ^user_id))
164 165
166
      # The visitor root carries the account's inference grants, and spend is
167
      # summed from those grants rather than kept in a counter — so deleting it
168
      # erases the record of what this account spent as well as the data it
169
      # asked to have removed. The allowance absorbs the difference before the
170
      # row goes, which leaves the account exactly the credit it had left and
171
      # keeps the deletion right from doubling as a way to refill it. See
172
      # `OpenAgents.Inference.Credit.absorb_erased_spend/2`.
173
      _erased_microusd = Credit.absorb_erased_spend(user_id, visitor_id)
174
165 175
      Repo.delete!(owner)
166 176
167 177
      {_deleted_receipts, nil} =
lib/openagents/data_rights/export_inventory.ex modified +14

@@ -284,6 +284,20 @@ defmodule OpenAgents.DataRights.ExportInventory do

284 284
          "carries the objective, the terminal report, usage, and the transcript, " <>
285 285
          "so the export ledger now reaches what the deletion cascade always did."
286 286
    },
287
    %{
288
      family: :credit,
289
      api?: true,
290
      status: :portable,
291
      mechanism: "GET /api/v1/credit",
292
      proof: {:test, "test/openagents_web/controllers/credit_controller_test.exs"},
293
      issue: nil,
294
      note:
295
        "The account's own inference money: what it was granted, what its " <>
296
          "grants metered, what is left, and how much of that spend carries no " <>
297
          "price. One read, scoped to the caller, so the account gets the whole " <>
298
          "record rather than a projection of it. The grants the spend is summed " <>
299
          "from leave with the threads that hold them."
300
    },
287 301
    %{
288 302
      family: :memory,
289 303
      api?: true,
lib/openagents/inference/credit.ex modified +111 -13

@@ -11,11 +11,20 @@ defmodule OpenAgents.Inference.Credit do

11 11
  bounded threads.
12 12
13 13
  So the credit is the account's, and a thread draws against it. Signing in is
14
  what buys the difference: `account_credit_microusd` for an account with a
15
  user behind it, `visitor_credit_microusd` for a browser that has not signed
16
  in. `remaining/1` is the allowance minus everything the account's grants have
17
  metered, and a thread is minted for exactly that, so one thread may spend the
18
  whole balance and the next is refused rather than handed a fresh ceiling.
14
  what buys the difference: an account with a user behind it holds the
15
  allowance recorded on that user, and a browser that has not signed in holds
16
  `visitor_credit_microusd`. `remaining/1` is the allowance minus everything
17
  the account's grants have metered, and a thread is minted for exactly that,
18
  so one thread may spend the whole balance and the next is refused rather than
19
  handed a fresh ceiling.
20
21
  The account allowance is a column rather than a constant because "what a new
22
  account is granted" and "what this account holds" are two questions and a
23
  constant can only answer one. `account_credit_microusd` in config is the
24
  first — the figure `new_account_allowance/0` returns and account creation
25
  writes onto the row. `users.credit_allowance_microusd` is the second, and it
26
  is what `allowance/1` reads. Lowering the config figure re-prices the next
27
  signup and leaves every existing account holding what it was granted.
19 28
20 29
  Spend is read from the grants themselves rather than kept in a second
21 30
  counter. `OpenAgents.Inference.record_usage/2` is the one writer of

@@ -26,6 +35,8 @@ defmodule OpenAgents.Inference.Credit do

26 35
27 36
  import Ecto.Query
28 37
38
  alias OpenAgents.Accounts.User
39
  alias OpenAgents.Conversations
29 40
  alias OpenAgents.Conversations.Visitor
30 41
  alias OpenAgents.Inference.Grant
31 42
  alias OpenAgents.Inference.Pricing

@@ -35,17 +46,32 @@ defmodule OpenAgents.Inference.Credit do

35 46
  What this account may spend in total, in microUSD.
36 47
37 48
  Signing in raises it, which is the whole point: an account with a user behind
38
  it holds `account_credit_microusd`, and a visitor that has only a browser key
39
  holds `visitor_credit_microusd`.
49
  it holds whatever `users.credit_allowance_microusd` records for that account,
50
  and a visitor that has only a browser key holds `visitor_credit_microusd`.
51
52
  The account figure is read from the row rather than from config, so two
53
  accounts can hold different allowances — which is what "new accounts are
54
  granted $20 and existing ones keep $100" means. A visitor root whose user row
55
  has somehow lost its allowance falls back to the visitor figure rather than
56
  to the new-account grant, because handing an unreadable account the current
57
  promotional figure is how a grant gets handed out twice.
40 58
  """
41 59
  @spec allowance(String.t()) :: non_neg_integer()
42 60
  def allowance(visitor_id) when is_binary(visitor_id) do
43
    if signed_in?(visitor_id), do: account_allowance(), else: visitor_allowance()
61
    case account_allowance(visitor_id) do
62
      microusd when is_integer(microusd) -> microusd
63
      nil -> visitor_allowance()
64
    end
44 65
  end
45 66
46
  @doc "What a signed-in account may spend in total, in microUSD."
47
  @spec account_allowance() :: non_neg_integer()
48
  def account_allowance, do: setting(:account_credit_microusd, 100_000_000)
67
  @doc """
68
  What a new account is granted, in microUSD.
69
70
  Read once, at account creation, and written onto the row. It is not what any
71
  particular account holds: ask `allowance/1` for that.
72
  """
73
  @spec new_account_allowance() :: non_neg_integer()
74
  def new_account_allowance, do: setting(:account_credit_microusd, 20_000_000)
49 75
50 76
  @doc "What a browser that has not signed in may spend in total, in microUSD."
51 77
  @spec visitor_allowance() :: non_neg_integer()

@@ -139,8 +165,80 @@ defmodule OpenAgents.Inference.Credit do

139 165
    }
140 166
  end
141 167
142
  defp signed_in?(visitor_id) do
143
    Repo.exists?(from v in Visitor, where: v.id == ^visitor_id and not is_nil(v.user_id))
168
  @doc """
169
  What this account has been granted and what it has spent of it, at once.
170
171
  One read for a client that renders a balance, and the honest shape of it:
172
  `remaining_microusd` alone would be a figure a reader could watch not move
173
  while a coder ran all day on an unpriced lane. `unpriced_calls` and
174
  `complete?` travel with it so the reader can tell "this account has spent
175
  $1.60" from "this account has spent at least nothing that anyone priced".
176
  """
177
  @spec account_credit(User.t()) :: %{
178
          allowance_microusd: non_neg_integer(),
179
          spent_microusd: non_neg_integer(),
180
          remaining_microusd: non_neg_integer(),
181
          unpriced_calls: non_neg_integer(),
182
          complete?: boolean()
183
        }
184
  def account_credit(%User{} = user) do
185
    user
186
    |> Conversations.ensure_owner_visitor()
187
    |> Map.fetch!(:id)
188
    |> balance()
189
  end
190
191
  @doc """
192
  Take erased spend out of the allowance, so a deletion does not refund it.
193
194
  Spend is summed from the account's grants, and those grants hang off the
195
  visitor root that `OpenAgents.DataRights.delete/3` removes — so the moment an
196
  account exercises its deletion right, `spent/1` reads zero again. The user row
197
  survives that deletion by design (DATA-004), which is what stops a second
198
  signup from being a second $20; nothing stopped the *same* row from being
199
  handed its whole balance back, once per deletion, for as long as the account
200
  cared to repeat it.
201
202
  The fix keeps the arithmetic rather than adding a counter to it: whatever the
203
  erased grants had metered is subtracted from the allowance, so
204
  `allowance - spent` is the same number a moment after the deletion as a
205
  moment before it. The account loses nothing it had left and recovers nothing
206
  it had spent.
207
  """
208
  @spec absorb_erased_spend(Ecto.UUID.t(), Ecto.UUID.t()) :: non_neg_integer()
209
  def absorb_erased_spend(user_id, visitor_id)
210
      when is_binary(user_id) and is_binary(visitor_id) do
211
    case spent(visitor_id) do
212
      0 ->
213
        0
214
215
      erased ->
216
        {1, nil} =
217
          Repo.update_all(
218
            from(user in User,
219
              where: user.id == ^user_id,
220
              update: [
221
                set: [
222
                  credit_allowance_microusd:
223
                    fragment("GREATEST(? - ?, 0)", user.credit_allowance_microusd, ^erased)
224
                ]
225
              ]
226
            ),
227
            []
228
          )
229
230
        erased
231
    end
232
  end
233
234
  defp account_allowance(visitor_id) do
235
    Repo.one(
236
      from visitor in Visitor,
237
        join: user in User,
238
        on: user.id == visitor.user_id,
239
        where: visitor.id == ^visitor_id,
240
        select: user.credit_allowance_microusd
241
    )
144 242
  end
145 243
146 244
  defp setting(key, default), do: Application.get_env(:openagents, key, default)
lib/openagents_web/api_route_authority.ex modified +1

@@ -201,6 +201,7 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

201 201
      "get /api/v1/chat/events" => {:required_bearer, :chat, :legacy},
202 202
      "post /api/v1/chat/turns" => {:required_bearer, :chat, :legacy},
203 203
      "get /api/v1/models" => {:required_bearer, :model, :envelope},
204
      "get /api/v1/credit" => {:required_bearer, :credit, :envelope},
204 205
      "post /api/v1/threads" => {:required_bearer, :thread, :envelope},
205 206
      "get /api/v1/threads" => {:required_bearer, :thread, :envelope},
206 207
      "get /api/v1/threads/:thread_id" => {:required_bearer, :thread, :envelope},
lib/openagents_web/controllers/api_extension_controller.ex modified +17 -8

@@ -604,18 +604,27 @@ defmodule OpenAgentsWeb.ApiExtensionController do

604 604
    }
605 605
  end
606 606
607
  # The two allowances a caller can be minted against. A thread's
608
  # `max_cost_microusd` is whichever of these applies minus what the account
609
  # has already spent, so publishing the allowance describes the balance while
610
  # publishing a per-thread number would describe nothing.
607
  # The two figures a caller can be minted against. A thread's
608
  # `max_cost_microusd` is whichever applies minus what the account has already
609
  # spent, so publishing them describes the balance while publishing a
610
  # per-thread number would describe nothing.
611
  #
612
  # `account_microusd` is what a *new* account is granted, not what every
613
  # account holds: the allowance is recorded per account, so one created while
614
  # the grant was larger still holds the larger figure. A caller that needs its
615
  # own number reads `GET /api/v1/credit`, which is the only place the account's
616
  # own allowance, spend, and remainder are stated.
611 617
  defp credit_allowances do
612 618
    %{
613
      "account_microusd" => Credit.account_allowance(),
619
      "account_microusd" => Credit.new_account_allowance(),
614 620
      "visitor_microusd" => Credit.visitor_allowance(),
615 621
      "description" =>
616
        "A signed-in account draws against `account_microusd` and an " <>
617
          "anonymous visitor against `visitor_microusd`, for the life of the " <>
618
          "account rather than per thread. A thread's grant is minted for the " <>
622
        "`account_microusd` is the credit a newly created account is granted " <>
623
          "and `visitor_microusd` is what an anonymous visitor holds, for the " <>
624
          "life of the account rather than per thread. An account's allowance " <>
625
          "is recorded on the account, so an older one may hold more than " <>
626
          "`account_microusd`: read `GET /api/v1/credit` for the caller's own " <>
627
          "allowance, spend, and remainder. A thread's grant is minted for the " <>
619 628
          "remainder, so `grant.max_cost_microusd` in the mint response is " <>
620 629
          "what is left rather than a fixed cap.",
621 630
      "unpriced_lanes" =>
lib/openagents_web/controllers/credit_controller.ex added +38

@@ -0,0 +1,38 @@

1
defmodule OpenAgentsWeb.CreditController do
2
  @moduledoc """
3
  `GET /api/v1/credit`: the inference money this account holds and has spent.
4
5
  The coder renders a balance from this rather than adding up what it saw,
6
  because what a turn spent is decided by the server that priced it: a client
7
  that kept its own total would be reporting one session's tokens as an
8
  account's spend, and would show the same figure to a second terminal that had
9
  already spent it.
10
11
  Four numbers and a flag, and the flag is the point. `remaining_microusd` is a
12
  ceiling rather than a balance while any of the account's calls landed on a
13
  model this deployment declares no rates for: an unpriced call records no
14
  cost, so it draws nothing down and the remainder does not move. `complete` is
15
  false exactly then, and `unpriced_calls` says how many calls the figure
16
  cannot see. A client that renders `remaining_microusd` without reading
17
  `complete` is a client that will show a full balance beside a session that
18
  spent all day (METER-001).
19
  """
20
21
  use OpenAgentsWeb, :controller
22
23
  alias OpenAgents.Inference.Credit
24
25
  def show(conn, _params) do
26
    credit = Credit.account_credit(conn.assigns.current_user)
27
28
    json(conn, %{
29
      "credit" => %{
30
        "allowance_microusd" => credit.allowance_microusd,
31
        "spent_microusd" => credit.spent_microusd,
32
        "remaining_microusd" => credit.remaining_microusd,
33
        "unpriced_calls" => credit.unpriced_calls,
34
        "complete" => credit.complete?
35
      }
36
    })
37
  end
38
end
lib/openagents_web/router.ex modified +5

@@ -642,6 +642,11 @@ defmodule OpenAgentsWeb.Router do

642 642
    # the thread scope because it names what a thread grant can be minted for.
643 643
    get "/models", ModelCatalogController, :index
644 644
645
    # What a thread is minted against. Same scope for the same reason: the
646
    # caller who can open a thread is the caller who reads what is left to
647
    # open one with.
648
    get "/credit", CreditController, :show
649
645 650
    post "/threads", ThreadController, :create
646 651
    post "/traces", TraceController, :create
647 652
    get "/threads", ThreadController, :index
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -313,7 +313,8 @@

313 313
    20260825220000,
314 314
    20260825230000,
315 315
    20260826010000,
316
    20260826103000
316
    20260826103000,
317
    20260826145554
317 318
  ],
318 319
  "required_tables": [
319 320
    "users",
priv/repo/migrations/20260826145554_grant_new_accounts_twenty_dollars_of_credit.exs added +68

@@ -0,0 +1,68 @@

1
defmodule OpenAgents.Repo.Migrations.GrantNewAccountsTwentyDollarsOfCredit do
2
  use Ecto.Migration
3
4
  @moduledoc """
5
  Move the account inference allowance from a config constant onto the account.
6
7
  The allowance used to be `account_credit_microusd`, read per request for
8
  every signed-in account alike. That constant cannot tell a new account from
9
  an existing one, so lowering it to $20 would not have granted new users $20 —
10
  it would have re-priced every account that already held $100, and put any
11
  account that had already metered more than $20 out of credit in the same
12
  deploy.
13
14
  So the figure moves onto `users`. The config constant stays as the default a
15
  new row is created with, and this column is the truth for an account that
16
  exists. Nothing else about the credit changes: spend is still summed from the
17
  grants' own `usage`, so there is still no second counter to disagree with a
18
  ledger.
19
20
  The backfill is the part worth reading. Every row that exists when this runs
21
  is by definition an existing account, and the decision is that existing
22
  accounts keep $100 — so the column is added nullable, every existing row is
23
  written to 100_000_000 explicitly, and only then does it become `NOT NULL`
24
  with the new-account default. Adding it with the default in one step would
25
  have written $20 across every existing account, which is the one outcome this
26
  change must not produce.
27
  """
28
29
  # $100, the allowance every account held before this migration ran.
30
  @existing_account_microusd 100_000_000
31
32
  # $20, the allowance a row created after this migration is granted. It
33
  # matches `:account_credit_microusd` in config, which is what
34
  # `OpenAgents.Inference.Credit.new_account_allowance/0` reads at insert time.
35
  # This default is the database's backstop for a row inserted by some other
36
  # path, not a second source of the figure.
37
  @new_account_microusd 20_000_000
38
39
  def up do
40
    alter table(:users) do
41
      add :credit_allowance_microusd, :bigint
42
    end
43
44
    execute("""
45
    UPDATE users
46
       SET credit_allowance_microusd = #{@existing_account_microusd}
47
     WHERE credit_allowance_microusd IS NULL
48
    """)
49
50
    alter table(:users) do
51
      modify :credit_allowance_microusd, :bigint,
52
        null: false,
53
        default: @new_account_microusd
54
    end
55
56
    create constraint(:users, :users_credit_allowance_nonnegative,
57
             check: "credit_allowance_microusd >= 0"
58
           )
59
  end
60
61
  def down do
62
    drop constraint(:users, :users_credit_allowance_nonnegative)
63
64
    alter table(:users) do
65
      remove :credit_allowance_microusd
66
    end
67
  end
68
end
test/openagents/inference/credit_test.exs modified +104

@@ -9,12 +9,16 @@ defmodule OpenAgents.Inference.CreditTest do

9 9
10 10
  use OpenAgents.DataCase, async: false
11 11
12
  import Ecto.Query
12 13
  import OpenAgentsWeb.ConnCase, only: [github_user: 1]
13 14
15
  alias OpenAgents.Accounts.User
14 16
  alias OpenAgents.Conversations
15 17
  alias OpenAgents.Conversations.Visitor
18
  alias OpenAgents.DataRights
16 19
  alias OpenAgents.Inference
17 20
  alias OpenAgents.Inference.Credit
21
  alias OpenAgents.Repo
18 22
  alias OpenAgents.Threads
19 23
20 24
  defp account(key) do

@@ -48,6 +52,106 @@ defmodule OpenAgents.Inference.CreditTest do

48 52
             Application.fetch_env!(:openagents, :account_credit_microusd)
49 53
  end
50 54
55
  # The allowance moved off the config constant and onto the account, so that
56
  # "a new account is granted $20" could stop meaning "every account is now
57
  # capped at $20". These three tests are what that distinction rests on.
58
  describe "the allowance is the account's own" do
59
    test "a new account is created holding the configured new-account grant" do
60
      user = github_user("credit-new-account")
61
62
      assert user.credit_allowance_microusd == Credit.new_account_allowance()
63
64
      assert Credit.allowance(Conversations.ensure_owner_visitor(user).id) ==
65
               Credit.new_account_allowance()
66
    end
67
68
    test "an account holding more than the current grant keeps it" do
69
      user = github_user("credit-grandfathered")
70
      owner = Conversations.ensure_owner_visitor(user)
71
72
      # What an account created before the grant was lowered looks like. The
73
      # migration writes exactly this figure across every row that already
74
      # existed; here it is set directly so the read is what is under test.
75
      {1, nil} =
76
        Repo.update_all(
77
          from(u in User, where: u.id == ^user.id),
78
          set: [credit_allowance_microusd: 100_000_000]
79
        )
80
81
      assert Credit.allowance(owner.id) == 100_000_000
82
      refute Credit.allowance(owner.id) == Credit.new_account_allowance()
83
      assert Credit.remaining(owner.id) == 100_000_000
84
    end
85
86
    test "signing in again does not re-grant the credit" do
87
      user = github_user("credit-returning")
88
      owner = Conversations.ensure_owner_visitor(user)
89
90
      {1, nil} =
91
        Repo.update_all(
92
          from(u in User, where: u.id == ^user.id),
93
          set: [credit_allowance_microusd: 3_000_000]
94
        )
95
96
      # `github_user/1` is the upsert the OAuth callback runs, so this is a
97
      # second sign-in by the same GitHub identity. It must not hand the
98
      # account the new-account figure again.
99
      same_user = github_user("credit-returning")
100
101
      assert same_user.id == user.id
102
      assert same_user.credit_allowance_microusd == 3_000_000
103
      assert Credit.allowance(owner.id) == 3_000_000
104
    end
105
  end
106
107
  # One GitHub identity holds one credited account: `users.github_id` carries a
108
  # unique index, so a second signup on the same identity is the same row. The
109
  # hole that leaves is deletion — `DataRights.delete/3` erases the visitor root
110
  # the account's grants hang off, and spend is summed from those grants. The
111
  # user row survives (DATA-004), so the allowance survives with it; without
112
  # this, everything the account had spent would come back.
113
  describe "deleting product data does not refund spend" do
114
    test "the allowance absorbs what the erased grants had metered" do
115
      user = github_user("credit-deletion")
116
      owner = Conversations.ensure_owner_visitor(user)
117
118
      {:ok, _metered} =
119
        Inference.record_usage(minted(owner.id), %{
120
          "output_tokens" => output_tokens_costing(4_000_000)
121
        })
122
123
      granted = Credit.allowance(owner.id)
124
      assert Credit.spent(owner.id) == 4_000_000
125
      left = Credit.remaining(owner.id)
126
      assert left == granted - 4_000_000
127
128
      {:ok, conversation} = Conversations.ensure_conversation(user)
129
      conversation_owner = Conversations.get_conversation_owner!(conversation)
130
      assert {:ok, :deleted} = DataRights.delete(user, conversation_owner, conversation)
131
132
      # A fresh visitor root, so the erased grants are gone and `spent/1` reads
133
      # zero again. What is left has to be the same number it was.
134
      next = Conversations.ensure_owner_visitor(Repo.get!(User, user.id))
135
      refute next.id == conversation_owner.id
136
137
      assert Credit.spent(next.id) == 0
138
      assert Credit.allowance(next.id) == granted - 4_000_000
139
      assert Credit.remaining(next.id) == left
140
    end
141
142
    test "an account that spent nothing keeps its whole allowance" do
143
      user = github_user("credit-deletion-unspent")
144
      granted = Credit.allowance(Conversations.ensure_owner_visitor(user).id)
145
146
      {:ok, conversation} = Conversations.ensure_conversation(user)
147
      conversation_owner = Conversations.get_conversation_owner!(conversation)
148
      assert {:ok, :deleted} = DataRights.delete(user, conversation_owner, conversation)
149
150
      next = Conversations.ensure_owner_visitor(Repo.get!(User, user.id))
151
      assert Credit.allowance(next.id) == granted
152
    end
153
  end
154
51 155
  test "a visitor that has not signed in holds the visitor credit" do
52 156
    assert Credit.allowance(visitor("anonymous")) ==
53 157
             Application.fetch_env!(:openagents, :visitor_credit_microusd)
test/openagents/repo/credit_allowance_backfill_test.exs added +147

@@ -0,0 +1,147 @@

1
defmodule OpenAgents.Repo.CreditAllowanceBackfillTest do
2
  @moduledoc """
3
  The migration that moved the account allowance onto `users`, run for real.
4
5
  Everything else about this change is safe to get wrong twice. This is not:
6
  the allowance went from $100 for every account to $20 for a new one, and a
7
  migration that wrote the new figure across the rows that already existed
8
  would have silently re-priced every live account and put any that had metered
9
  more than $20 out of credit in the same deploy.
10
11
  So the backfill is not asserted here, it is executed. The migration is rolled
12
  back so the rows under test are genuinely pre-migration rows — the column does
13
  not exist while they sit there — and then run forward again. What the test
14
  reads afterwards is what the migration wrote.
15
16
  It runs inside the sandbox transaction the case checks out. PostgreSQL makes
17
  DDL transactional, so the column disappearing and reappearing is invisible to
18
  every other connection and is rolled back with the rest of the test.
19
  """
20
21
  use OpenAgents.DataCase, async: false
22
23
  alias Ecto.Adapters.SQL
24
  alias OpenAgents.Repo.Migrations.GrantNewAccountsTwentyDollarsOfCredit, as: Migration
25
26
  @version 20_260_826_145_554
27
28
  # Migrations are not on the compile path, so the module is loaded from the
29
  # file the repository will actually run. Found by its version rather than by
30
  # a hard-coded name, so renaming the file fails this test instead of quietly
31
  # leaving it testing nothing.
32
  setup_all do
33
    [path] = Path.wildcard("priv/repo/migrations/#{@version}_*.exs")
34
    Code.require_file(path)
35
    :ok
36
  end
37
38
  # $100. Written out rather than read from the migration, because a test that
39
  # asks the code under test what the right answer is proves only that it is
40
  # self-consistent.
41
  @existing_account_microusd 100_000_000
42
43
  # $20, the figure config carries for a new account.
44
  @new_account_microusd 20_000_000
45
46
  defp insert_user(login) do
47
    id = Ecto.UUID.generate()
48
49
    SQL.query!(
50
      Repo,
51
      """
52
      INSERT INTO users
53
        (id, github_id, github_login, github_avatar_url, status, inserted_at, updated_at)
54
      VALUES ($1, $2, $3, $4, 'active', NOW(), NOW())
55
      """,
56
      [
57
        Ecto.UUID.dump!(id),
58
        :erlang.phash2(login, 1_000_000) + 1,
59
        login,
60
        "https://avatars.githubusercontent.com/u/1?v=4"
61
      ]
62
    )
63
64
    id
65
  end
66
67
  defp allowance_of(id) do
68
    %{rows: [[allowance]]} =
69
      SQL.query!(
70
        Repo,
71
        "SELECT credit_allowance_microusd FROM users WHERE id = $1",
72
        [Ecto.UUID.dump!(id)]
73
      )
74
75
    allowance
76
  end
77
78
  defp column_exists? do
79
    %{rows: [[count]]} =
80
      SQL.query!(
81
        Repo,
82
        """
83
        SELECT COUNT(*) FROM information_schema.columns
84
         WHERE table_name = 'users' AND column_name = 'credit_allowance_microusd'
85
        """,
86
        []
87
      )
88
89
    count == 1
90
  end
91
92
  test "every account that existed before the migration keeps $100" do
93
    veterans = Enum.map(1..3, &insert_user("veteran-#{&1}"))
94
95
    # Down first: the rows above become rows from before this change existed,
96
    # with no allowance recorded anywhere, which is the only state the backfill
97
    # is claimed to handle.
98
    :ok = Ecto.Migrator.down(Repo, @version, Migration, log: false, migration_lock: false)
99
    refute column_exists?()
100
101
    :ok = Ecto.Migrator.up(Repo, @version, Migration, log: false, migration_lock: false)
102
    assert column_exists?()
103
104
    for id <- veterans do
105
      assert allowance_of(id) == @existing_account_microusd
106
    end
107
  end
108
109
  test "an account created after the migration is granted $20" do
110
    :ok = Ecto.Migrator.down(Repo, @version, Migration, log: false, migration_lock: false)
111
    :ok = Ecto.Migrator.up(Repo, @version, Migration, log: false, migration_lock: false)
112
113
    # Inserted through raw SQL, so the figure comes from the column default the
114
    # migration installed rather than from anything the application chose.
115
    fresh = insert_user("post-migration")
116
117
    assert allowance_of(fresh) == @new_account_microusd
118
  end
119
120
  test "the migration does not lower an account that already holds $100" do
121
    # The realistic double-run: a deploy that migrates, an account that keeps
122
    # its grandfathered figure, and a rollback-and-forward afterwards. The
123
    # second pass must not treat $100 as a value to overwrite — it writes only
124
    # where nothing is recorded.
125
    veteran = insert_user("twice-migrated")
126
127
    :ok = Ecto.Migrator.down(Repo, @version, Migration, log: false, migration_lock: false)
128
    :ok = Ecto.Migrator.up(Repo, @version, Migration, log: false, migration_lock: false)
129
    assert allowance_of(veteran) == @existing_account_microusd
130
131
    :ok = Ecto.Migrator.down(Repo, @version, Migration, log: false, migration_lock: false)
132
    :ok = Ecto.Migrator.up(Repo, @version, Migration, log: false, migration_lock: false)
133
    assert allowance_of(veteran) == @existing_account_microusd
134
  end
135
136
  test "an allowance cannot be driven below zero" do
137
    veteran = insert_user("floor")
138
139
    assert_raise Postgrex.Error, fn ->
140
      SQL.query!(
141
        Repo,
142
        "UPDATE users SET credit_allowance_microusd = -1 WHERE id = $1",
143
        [Ecto.UUID.dump!(veteran)]
144
      )
145
    end
146
  end
147
end
test/openagents_web/controllers/api_extension_governance_test.exs modified +6 -1

@@ -185,7 +185,12 @@ defmodule OpenAgentsWeb.ApiExtensionGovernanceTest do

185 185
    # The cost figure is the account's credit rather than a per-thread cap, so
186 186
    # the document publishes the allowances and the mint reports the remainder.
187 187
    refute Map.has_key?(limits["grant"], "max_cost_microusd")
188
    assert limits["credit"]["account_microusd"] == Credit.account_allowance()
188
    # The published figure is what a new account is granted. It is no longer
189
    # what every account holds — the allowance is recorded per account — and
190
    # the document says so, pointing a caller at `GET /api/v1/credit` for its
191
    # own.
192
    assert limits["credit"]["account_microusd"] == Credit.new_account_allowance()
193
    assert limits["credit"]["description"] =~ "GET /api/v1/credit"
189 194
    assert limits["credit"]["visitor_microusd"] == Credit.visitor_allowance()
190 195
191 196
    created =
test/openagents_web/controllers/credit_controller_test.exs added +122

@@ -0,0 +1,122 @@

1
defmodule OpenAgentsWeb.CreditControllerTest do
2
  @moduledoc """
3
  `GET /api/v1/credit`: what the coder's status bar reads.
4
5
  The reason this endpoint exists, rather than a client keeping its own total,
6
  is that spend is the server's: it prices the call, and a second terminal on
7
  the same account spends the same money. The reason it carries `complete` is
8
  METER-001 — an unpriced call records no cost, so the remainder does not move,
9
  and a client that rendered the remainder alone would show a full balance
10
  beside a session that had run all day.
11
  """
12
13
  use OpenAgentsWeb.ConnCase, async: false
14
15
  import Ecto.Query
16
17
  alias OpenAgents.Accounts.User
18
  alias OpenAgents.Conversations
19
  alias OpenAgents.Conversations.Visitor
20
  alias OpenAgents.Inference
21
  alias OpenAgents.Inference.Credit
22
  alias OpenAgents.Repo
23
  alias OpenAgents.Threads
24
25
  # `put_chat_api_token/2` mints the token for this account, so naming the key
26
  # the same way is how a test reaches the user behind the credential it sent.
27
  defp account(key), do: github_user("api-token-" <> key)
28
29
  defp output_tokens_costing(microusd) do
30
    div(
31
      microusd * 1_000,
32
      Application.fetch_env!(:openagents, :inference_output_price_microusd_per_ktoken)
33
    )
34
  end
35
36
  defp minted(visitor_id) do
37
    {:ok, thread} = Threads.open(%Visitor{id: visitor_id}, "spend some credit")
38
    {:ok, _fenced, grant, _token} = Threads.mint_grant(thread)
39
    grant
40
  end
41
42
  test "a new account reads its whole grant, with nothing unseen", %{conn: conn} do
43
    body =
44
      conn
45
      |> put_chat_api_token("credit-fresh")
46
      |> get(~p"/api/v1/credit")
47
      |> json_response(200)
48
49
    assert body["credit"]["allowance_microusd"] == Credit.new_account_allowance()
50
    assert body["credit"]["spent_microusd"] == 0
51
    assert body["credit"]["remaining_microusd"] == Credit.new_account_allowance()
52
    assert body["credit"]["unpriced_calls"] == 0
53
    assert body["credit"]["complete"] == true
54
  end
55
56
  test "priced spend comes off the remainder", %{conn: conn} do
57
    owner = "credit-spent" |> account() |> Conversations.ensure_owner_visitor()
58
59
    {:ok, _metered} =
60
      Inference.record_usage(minted(owner.id), %{
61
        "output_tokens" => output_tokens_costing(1_600_000)
62
      })
63
64
    body =
65
      conn
66
      |> put_chat_api_token("credit-spent")
67
      |> get(~p"/api/v1/credit")
68
      |> json_response(200)
69
70
    assert body["credit"]["spent_microusd"] == 1_600_000
71
72
    assert body["credit"]["remaining_microusd"] ==
73
             Credit.new_account_allowance() - 1_600_000
74
75
    assert body["credit"]["complete"] == true
76
  end
77
78
  # The path the coder's own lane is on today. The figure does not move, and
79
  # the response has to say why rather than leave the client to discover it.
80
  test "an unpriced call leaves the remainder still and says so", %{conn: conn} do
81
    owner = "credit-unpriced" |> account() |> Conversations.ensure_owner_visitor()
82
    luna = Application.fetch_env!(:openagents, :openai_model)
83
84
    {:ok, thread} = Threads.open(%Visitor{id: owner.id}, "Run the unpriced lane", model: luna)
85
    {:ok, _fenced, grant, _token} = Threads.mint_grant(thread)
86
    {:ok, _metered} = Inference.record_usage(grant, %{"output_tokens" => 500_000})
87
88
    body =
89
      conn
90
      |> put_chat_api_token("credit-unpriced")
91
      |> get(~p"/api/v1/credit")
92
      |> json_response(200)
93
94
    assert body["credit"]["spent_microusd"] == 0
95
    assert body["credit"]["remaining_microusd"] == Credit.new_account_allowance()
96
    assert body["credit"]["unpriced_calls"] == 1
97
    assert body["credit"]["complete"] == false
98
  end
99
100
  test "an account holding a grandfathered allowance reads its own figure", %{conn: conn} do
101
    user = account("credit-grandfathered-api")
102
103
    {1, nil} =
104
      Repo.update_all(
105
        from(u in User, where: u.id == ^user.id),
106
        set: [credit_allowance_microusd: 100_000_000]
107
      )
108
109
    body =
110
      conn
111
      |> put_chat_api_token("credit-grandfathered-api")
112
      |> get(~p"/api/v1/credit")
113
      |> json_response(200)
114
115
    assert body["credit"]["allowance_microusd"] == 100_000_000
116
    assert body["credit"]["remaining_microusd"] == 100_000_000
117
  end
118
119
  test "an anonymous caller is refused rather than shown an account", %{conn: conn} do
120
    assert conn |> get(~p"/api/v1/credit") |> json_response(401)
121
  end
122
end
test/openagents_web/controllers/thread_controller_test.exs modified +9 -2

@@ -293,9 +293,16 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

293 293
        |> json_response(402)
294 294
295 295
      assert body["code"] == "credit_exhausted"
296
      assert body["message"] =~ "$100.00"
296
297
      # The account's own allowance, not a deployment-wide figure: it is
298
      # recorded per account, so the refusal names what this one was granted.
299
      # Read from the same source the refusal reads, because a hard-coded
300
      # dollar amount here would go red the next time the grant changes without
301
      # anything being wrong.
302
      granted = "$#{:erlang.float_to_binary(allowance / 1_000_000, decimals: 2)}"
303
      assert body["message"] =~ granted
297 304
      assert [message] = body["errors"]["credit"]
298
      assert message =~ "$100.00"
305
      assert message =~ granted
299 306
    end
300 307
301 308
    test "the cap counts one account's threads, never another's", %{conn: conn} do
test/openagents_web/install_script_test.exs modified -17

@@ -234,21 +234,4 @@ defmodule OpenAgentsWeb.InstallScriptTest do

234 234
             "the installer still claims it installs oa only"
235 235
    end
236 236
  end
237
238
  # Pull one shell function out of the installer so a test can run the real
239
  # thing rather than a restatement of it.
240
  defp extract_function(script, name) do
241
    script
242
    |> String.split("\n")
243
    |> Enum.drop_while(&(!String.starts_with?(&1, "#{name}() {")))
244
    |> Enum.take_while(&(&1 != "}"))
245
    |> Kernel.++(["}"])
246
    |> Enum.join("\n")
247
  end
248
249
  # The ERE the installer greps the shell rc file with.
250
  defp shadow_pattern(script) do
251
    [_, pattern] = Regex.run(~r/grep -Eqs '([^']+)'/, script)
252
    pattern
253
  end
254 237
end

This page updates live while a promote is in flight · changelog