Say what a coder session cost

b65a63af5d22 · AtlantisPleb · · parent 6e98134d40b3

Say what a coder session cost

A thread read told you everything about a session except the one thing
a person paying for it wants: what it spent. The numbers were being
recorded all along, on the grant, and no route gave them back
(openagents.com #132's second acceptance criterion).

`OpenAgents.Threads.spend/1` sums the calls and usage across every
grant a thread has held, and the thread view carries it. Summing
matters rather than reading the live grant: resuming re-mints, so
authority is spread across grants, and the live one alone reports a
resumed session as though it had just started.

Absent stays absent. A dimension no provider reported is not summed
into existence as a zero, because a zero reads as a measurement and
this is the absence of one — the same rule the cached-token split
follows (#220).

The grant-token reach proof classified the new export as it landed,
which is exactly what that enumeration is for: `spend/1` returns
aggregate numbers and no plaintext token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

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 356 · 2026-08-25T09:34:27.466632Z

Changed files

  • modified lib/openagents/threads.ex
  • modified lib/openagents_web/controllers/thread_controller.ex
  • modified test/openagents/threads/grant_token_reach_test.exs
  • modified test/openagents/threads_test.exs

Diff

4 files changed, +91 -1

lib/openagents/threads.ex modified +43

@@ -679,6 +679,49 @@ defmodule OpenAgents.Threads do

679 679
680 680
  @doc "How many threads one account may hold open at once, or `nil` for no limit."
681 681
  @spec maximum_open_per_account() :: pos_integer() | nil
682
  @doc """
683
  What a thread has spent, summed across every grant it has ever held.
684
685
  A thread's authority is re-minted on resume — each mint is a new grant with
686
  a new generation — so the calls and tokens a session cost are spread across
687
  grants rather than sitting on one. Summing them is the only honest answer to
688
  "what did this session cost": reading the live grant alone reports a resumed
689
  session as though it had just started.
690
691
  Absent stays absent. A dimension no provider reported is not summed into
692
  existence as a zero, because a zero reads as a measurement and this is the
693
  absence of one (#220).
694
  """
695
  @spec spend(Thread.t() | String.t()) :: %{
696
          calls: non_neg_integer(),
697
          grants: non_neg_integer(),
698
          usage: map()
699
        }
700
  def spend(%Thread{id: thread_id}), do: spend(thread_id)
701
702
  def spend(thread_id) when is_binary(thread_id) do
703
    grants =
704
      from(grant in Grant,
705
        where: grant.thread_id == ^thread_id,
706
        select: {grant.call_count, grant.usage}
707
      )
708
      |> Repo.all()
709
710
    usage =
711
      Enum.reduce(grants, %{}, fn {_calls, usage}, acc ->
712
        Enum.reduce(usage || %{}, acc, fn
713
          {key, value}, inner when is_integer(value) -> Map.update(inner, key, value, &(&1 + value))
714
          {_key, _value}, inner -> inner
715
        end)
716
      end)
717
718
    %{
719
      calls: Enum.sum(Enum.map(grants, fn {calls, _usage} -> calls || 0 end)),
720
      grants: length(grants),
721
      usage: usage
722
    }
723
  end
724
682 725
  def maximum_open_per_account, do: setting(:maximum_open_threads_per_account, nil)
683 726
684 727
  @doc "How many threads this account currently holds open."
lib/openagents_web/controllers/thread_controller.ex modified +10 -1

@@ -665,10 +665,19 @@ defmodule OpenAgentsWeb.ThreadController do

665 665
      "report" => thread.report,
666 666
      "error_code" => thread.error_code,
667 667
      "started_at" => stamp(thread.started_at),
668
      "completed_at" => stamp(thread.completed_at)
668
      "completed_at" => stamp(thread.completed_at),
669
      # What the session cost, summed across every grant this thread has held
670
      # (#132). A resumed thread re-mints, so the live grant alone would
671
      # under-report; a dimension no provider gave is absent rather than zero.
672
      "spend" => spend_view(thread)
669 673
    }
670 674
  end
671 675
676
  defp spend_view(%Thread{} = thread) do
677
    spend = Threads.spend(thread)
678
    %{"calls" => spend.calls, "grants" => spend.grants, "usage" => spend.usage}
679
  end
680
672 681
  # The plaintext token exists exactly once, here. Everything else in this map
673 682
  # is what a client needs to spend it: where to send the call, which model the
674 683
  # proxy will pin, when the authority ends, and what it may spend.
test/openagents/threads/grant_token_reach_test.exs modified +1

@@ -74,6 +74,7 @@ defmodule OpenAgents.Threads.GrantTokenReachTest do

74 74
    {:reap_expired, 1} => :scoped_by_owner,
75 75
    {:record_event, 3} => :thread_struct,
76 76
    {:record_events, 2} => :thread_struct,
77
    {:spend, 1} => :thread_struct,
77 78
    {:subscribe, 1} => :thread_struct
78 79
  }
79 80
test/openagents/threads_test.exs modified +37

@@ -506,4 +506,41 @@ defmodule OpenAgents.ThreadsTest do

506 506
    Application.put_env(:openagents, :thread_grant_ttl_seconds, -1)
507 507
    on_exit(fn -> Application.put_env(:openagents, :thread_grant_ttl_seconds, previous) end)
508 508
  end
509
510
  describe "what a thread spent" do
511
    test "sums calls and usage across every grant the thread has held" do
512
      user = owner("spend-sums")
513
      {:ok, thread, grant, _token} = Threads.open_and_mint(user, "Spend some")
514
515
      {:ok, _} = Inference.record_usage(grant, %{"input_tokens" => 100, "output_tokens" => 10})
516
517
      # Resuming re-mints: the second grant is where later spend lands, and a
518
      # reader asking what the session cost must see both.
519
      {:ok, resumed, second, _token} = Threads.mint_grant(thread)
520
      {:ok, _} = Inference.record_usage(second, %{"input_tokens" => 40, "output_tokens" => 5})
521
522
      spend = Threads.spend(resumed)
523
      assert spend.grants == 2
524
      assert spend.calls == 2
525
      assert spend.usage["input_tokens"] == 140
526
      assert spend.usage["output_tokens"] == 15
527
    end
528
529
    test "a dimension no provider reported is absent rather than zero" do
530
      user = owner("spend-partial")
531
      {:ok, thread, grant, _token} = Threads.open_and_mint(user, "Partial usage")
532
      {:ok, _} = Inference.record_usage(grant, %{"input_tokens" => 7})
533
534
      spend = Threads.spend(thread)
535
      assert spend.usage["input_tokens"] == 7
536
      refute Map.has_key?(spend.usage, "cache_read_input_tokens")
537
    end
538
539
    test "a thread that never spent reports nothing spent" do
540
      user = owner("spend-none")
541
      {:ok, thread} = Threads.open(user, "Never spent")
542
543
      assert Threads.spend(thread) == %{calls: 0, grants: 0, usage: %{}}
544
    end
545
  end
509 546
end

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