Track productive token usage, not just raw volume

2ff9f625e80a · Devin AI · · parent 2c82cc7cd07d

Track productive token usage, not just raw volume

Add OpenAgents.TokenProductivity, a read-only aggregate that splits raw
token volume (turn receipts, voice sessions, work jobs, SCV runs) from
productive tokens attached to durable outcomes: merged work, closed
issues, and verified receipts. Report cache hit rate, input versus
output split, and per-provider throughput from completed provider
steps, and surface it on the operator page /admin/tokens.

Closes #43

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.com>
Closes
#43

Deploy story

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

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • added lib/openagents/token_productivity.ex
  • added lib/openagents_web/live/admin_tokens_live.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • added test/openagents/token_productivity_test.exs
  • added test/openagents_web/live/admin_tokens_live_test.exs

Diff

6 files changed, +922 -0

lib/openagents/token_productivity.ex added +318

@@ -0,0 +1,318 @@

1
defmodule OpenAgents.TokenProductivity do
2
  @moduledoc """
3
  Productive token accounting: raw volume versus tokens that produced durable
4
  outcomes.
5
6
  Raw volume alone rewards burn. This module reads the four tables that hold
7
  token truth — `turn_receipts`, `voice_sessions`, `work_jobs`, and `scv_runs`
8
  — and splits the same tokens a second way: how many are attached to evidence
9
  that the work landed. Three buckets, strongest evidence first, each usage
10
  row counted at most once:
11
12
    * **Merged work** — an SCV run whose issue carries a merged pull request.
13
    * **Closed issues** — an SCV run whose issue closed without a merged pull
14
      request.
15
    * **Verified receipts** — an SCV run that succeeded with a digest-verified
16
      terminal receipt, or a work job that completed with its bounded report,
17
      where no stronger outcome evidence exists.
18
19
  Typed and spoken conversation tokens count toward raw volume only: a chat
20
  turn has no durable outcome record to attribute it to.
21
22
  ## Which usage counts
23
24
  The same double-count rule as `OpenAgents.Leaderboard` applies:
25
  `turn_receipts.usage` is already the merge of every tool-loop provider call
26
  in a typed turn, and `voice_sessions.usage` is already the merge of that
27
  session's responses, so `turn_provider_steps.usage` is never summed into raw
28
  totals. Provider steps are read separately — and only for the per-provider
29
  throughput table, where attributing tokens to the provider that produced
30
  them is the whole point. `work_jobs` rows come from the OpenCode-driven work
31
  machinery and `scv_runs` rows from the Codex driver; the two never describe
32
  the same run.
33
34
  ## Rates
35
36
  Stored usage maps spell their cache field three ways: `cached_input_tokens`
37
  (typed turns, Codex runs) and `input_cached_tokens` (voice) count cached
38
  tokens inside `input_tokens`, while `cache_read_tokens` (OpenCode) counts
39
  them separately. The cache hit rate therefore divides all cached tokens by
40
  `input_tokens` plus the exclusive `cache_read_tokens`, so both spellings
41
  land in the same denominator. The input share uses that same inclusive
42
  input count over input plus output.
43
  """
44
45
  import Ecto.Query
46
47
  alias OpenAgents.Conversations.ProviderStep
48
  alias OpenAgents.Conversations.TurnReceipt
49
  alias OpenAgents.Issues.Issue
50
  alias OpenAgents.PullRequests.PullRequest
51
  alias OpenAgents.Repo
52
  alias OpenAgents.SCV.Execution
53
  alias OpenAgents.Voice.Session
54
  alias OpenAgents.Work.Job
55
56
  @schema "openagents.token_productivity.v1"
57
58
  @empty_totals %{
59
    input_tokens: 0,
60
    output_tokens: 0,
61
    cached_input_tokens: 0,
62
    cache_read_tokens: 0,
63
    total_tokens: 0
64
  }
65
66
  @type totals :: %{
67
          input_tokens: non_neg_integer(),
68
          output_tokens: non_neg_integer(),
69
          cached_input_tokens: non_neg_integer(),
70
          cache_read_tokens: non_neg_integer(),
71
          total_tokens: non_neg_integer()
72
        }
73
74
  @type provider_row :: %{
75
          provider_id: String.t(),
76
          steps: non_neg_integer(),
77
          input_tokens: non_neg_integer(),
78
          output_tokens: non_neg_integer(),
79
          cached_input_tokens: non_neg_integer(),
80
          total_tokens: non_neg_integer(),
81
          duration_ms: non_neg_integer(),
82
          tokens_per_second: float() | nil
83
        }
84
85
  @type report :: %{
86
          schema: String.t(),
87
          generated_at: DateTime.t(),
88
          raw: totals(),
89
          sources: %{
90
            typed_turns: totals(),
91
            voice_sessions: totals(),
92
            work_jobs: totals(),
93
            scv_runs: totals()
94
          },
95
          productive: %{
96
            merged_work: totals(),
97
            closed_issues: totals(),
98
            verified_receipts: totals(),
99
            total_tokens: non_neg_integer(),
100
            share: float() | nil
101
          },
102
          cache: %{
103
            cached_input_tokens: non_neg_integer(),
104
            input_tokens: non_neg_integer(),
105
            hit_rate: float() | nil
106
          },
107
          split: %{
108
            input_tokens: non_neg_integer(),
109
            output_tokens: non_neg_integer(),
110
            input_share: float() | nil
111
          },
112
          providers: [provider_row()]
113
        }
114
115
  # SUM of one numeric JSON field, defensive against absent keys and
116
  # non-numeric values, exactly like the leaderboard's usage fragments. The
117
  # key is a compile-time literal, inlined so the operator stays jsonb ->> text.
118
  defmacrop summed(usage, key) when is_binary(key) do
119
    expression =
120
      "COALESCE(SUM(CASE WHEN ? ->> '#{key}' ~ '^[0-9]+$' THEN (? ->> '#{key}')::bigint ELSE 0 END), 0)"
121
122
    quote do
123
      fragment(unquote(expression), unquote(usage), unquote(usage))
124
    end
125
  end
126
127
  # Per-row effective total: providers that omit total_tokens still count.
128
  defmacrop summed_effective_total(usage) do
129
    quote do
130
      fragment(
131
        """
132
        COALESCE(SUM(GREATEST(
133
          CASE WHEN ? ->> 'total_tokens' ~ '^[0-9]+$' THEN (? ->> 'total_tokens')::bigint ELSE 0 END,
134
          CASE WHEN ? ->> 'input_tokens' ~ '^[0-9]+$' THEN (? ->> 'input_tokens')::bigint ELSE 0 END
135
            + CASE WHEN ? ->> 'output_tokens' ~ '^[0-9]+$' THEN (? ->> 'output_tokens')::bigint ELSE 0 END
136
        )), 0)
137
        """,
138
        unquote(usage),
139
        unquote(usage),
140
        unquote(usage),
141
        unquote(usage),
142
        unquote(usage),
143
        unquote(usage)
144
      )
145
    end
146
  end
147
148
  @doc "Computes the full report straight from PostgreSQL."
149
  @spec report() :: report()
150
  def report do
151
    sources = %{
152
      typed_turns: totals(from(receipt in TurnReceipt, as: :usage_row)),
153
      voice_sessions: totals(from(session in Session, as: :usage_row)),
154
      work_jobs: totals(from(job in Job, as: :usage_row)),
155
      scv_runs: totals(from(run in Execution, as: :usage_row))
156
    }
157
158
    raw =
159
      Enum.reduce(Map.values(sources), @empty_totals, &merge_totals/2)
160
161
    merged_work = totals(merged_work_query())
162
    closed_issues = totals(closed_issues_query())
163
164
    verified_receipts =
165
      merge_totals(totals(receipt_runs_query()), totals(completed_jobs_query()))
166
167
    productive_total =
168
      merged_work.total_tokens + closed_issues.total_tokens + verified_receipts.total_tokens
169
170
    cached = raw.cached_input_tokens
171
    input_inclusive = raw.input_tokens + raw.cache_read_tokens
172
173
    %{
174
      schema: @schema,
175
      generated_at: DateTime.utc_now(),
176
      raw: raw,
177
      sources: sources,
178
      productive: %{
179
        merged_work: merged_work,
180
        closed_issues: closed_issues,
181
        verified_receipts: verified_receipts,
182
        total_tokens: productive_total,
183
        share: ratio(productive_total, raw.total_tokens)
184
      },
185
      cache: %{
186
        cached_input_tokens: cached,
187
        input_tokens: input_inclusive,
188
        hit_rate: ratio(cached, input_inclusive)
189
      },
190
      split: %{
191
        input_tokens: input_inclusive,
192
        output_tokens: raw.output_tokens,
193
        input_share: ratio(input_inclusive, input_inclusive + raw.output_tokens)
194
      },
195
      providers: providers()
196
    }
197
  end
198
199
  @doc """
200
  Per-provider volume and throughput from completed provider steps.
201
202
  Provider steps carry each attempt's own usage and wall clock, so this is the
203
  one place they are read: tokens per second come from output tokens over the
204
  step's recorded duration.
205
  """
206
  @spec providers() :: [provider_row()]
207
  def providers do
208
    from(step in ProviderStep,
209
      as: :usage_row,
210
      where: step.status == "completed" and not is_nil(step.usage),
211
      group_by: step.provider_id,
212
      order_by: [desc: summed_effective_total(step.usage)],
213
      select: %{
214
        provider_id: step.provider_id,
215
        steps: count(step.id),
216
        input_tokens: summed(step.usage, "input_tokens"),
217
        output_tokens: summed(step.usage, "output_tokens"),
218
        cached_input_tokens: summed(step.usage, "cached_input_tokens"),
219
        total_tokens: summed_effective_total(step.usage),
220
        duration_ms:
221
          fragment(
222
            "COALESCE(SUM(EXTRACT(EPOCH FROM (? - ?)) * 1000) FILTER (WHERE ? IS NOT NULL), 0)::bigint",
223
            step.completed_at,
224
            step.started_at,
225
            step.completed_at
226
          )
227
      }
228
    )
229
    |> Repo.all()
230
    |> Enum.map(fn row ->
231
      row =
232
        Map.new(row, fn
233
          {:provider_id, value} -> {:provider_id, value}
234
          {key, value} -> {key, integer(value)}
235
        end)
236
237
      Map.put(row, :tokens_per_second, throughput(row.output_tokens, row.duration_ms))
238
    end)
239
  end
240
241
  # Every totals query names its usage-bearing binding :usage_row so one
242
  # select works for all of them.
243
  defp totals(query) do
244
    query
245
    |> select([usage_row: row], %{
246
      input_tokens: summed(row.usage, "input_tokens"),
247
      output_tokens: summed(row.usage, "output_tokens"),
248
      cached_input_tokens:
249
        summed(row.usage, "cached_input_tokens") +
250
          summed(row.usage, "input_cached_tokens") +
251
          summed(row.usage, "cache_read_tokens"),
252
      cache_read_tokens: summed(row.usage, "cache_read_tokens"),
253
      total_tokens: summed_effective_total(row.usage)
254
    })
255
    |> Repo.one()
256
    |> Map.new(fn {key, value} -> {key, integer(value)} end)
257
  end
258
259
  # A run's issue with a merged pull request is the strongest outcome evidence.
260
  defp merged_work_query do
261
    from(run in Execution,
262
      as: :usage_row,
263
      join: pull_request in PullRequest,
264
      on: pull_request.issue_id == run.issue_id,
265
      where: not is_nil(pull_request.merged_at)
266
    )
267
  end
268
269
  # Closed without a merged pull request: the issue itself was the outcome.
270
  defp closed_issues_query do
271
    from(run in Execution,
272
      as: :usage_row,
273
      join: issue in Issue,
274
      on: issue.id == run.issue_id,
275
      left_join: pull_request in PullRequest,
276
      on: pull_request.issue_id == run.issue_id,
277
      where: issue.state == "closed",
278
      where: is_nil(pull_request.id) or is_nil(pull_request.merged_at)
279
    )
280
  end
281
282
  # Succeeded runs carry a digest-verified terminal receipt; count the ones no
283
  # stronger bucket already counted.
284
  defp receipt_runs_query do
285
    from(run in Execution,
286
      as: :usage_row,
287
      left_join: issue in Issue,
288
      on: issue.id == run.issue_id,
289
      left_join: pull_request in PullRequest,
290
      on: pull_request.issue_id == run.issue_id,
291
      where: run.status == "succeeded",
292
      where: is_nil(issue.id) or issue.state != "closed",
293
      where: is_nil(pull_request.id) or is_nil(pull_request.merged_at)
294
    )
295
  end
296
297
  # A completed work job's terminal row must carry its bounded report — that
298
  # report is the receipt.
299
  defp completed_jobs_query do
300
    from(job in Job, as: :usage_row, where: job.status == "completed")
301
  end
302
303
  defp merge_totals(left, right) do
304
    Map.new(@empty_totals, fn {key, _zero} ->
305
      {key, Map.fetch!(left, key) + Map.fetch!(right, key)}
306
    end)
307
  end
308
309
  defp ratio(_numerator, denominator) when denominator in [0, nil], do: nil
310
  defp ratio(numerator, denominator), do: numerator / denominator
311
312
  defp throughput(_output_tokens, duration_ms) when duration_ms <= 0, do: nil
313
  defp throughput(output_tokens, duration_ms), do: output_tokens / (duration_ms / 1000)
314
315
  defp integer(value) when is_integer(value), do: value
316
  defp integer(%Decimal{} = value), do: Decimal.to_integer(value)
317
  defp integer(_value), do: 0
318
end
lib/openagents_web/live/admin_tokens_live.ex added +229

@@ -0,0 +1,229 @@

1
defmodule OpenAgentsWeb.AdminTokensLive do
2
  @moduledoc """
3
  Operator view of productive token usage versus raw volume.
4
5
  Every number is computed by `OpenAgents.TokenProductivity` at request time
6
  straight from PostgreSQL, the same tables the leaderboard reads, so the
7
  surface adds no second aggregation authority. Read-only and operator-gated.
8
9
  It shows aggregate token counts and rates only. It never renders
10
  conversation content, objectives, reports, or anything a person wrote.
11
  """
12
13
  use OpenAgentsWeb, :live_view
14
15
  alias OpenAgents.Accounts
16
  alias OpenAgents.TokenProductivity
17
18
  @impl true
19
  def mount(_params, _session, socket) do
20
    if Accounts.admin?(socket.assigns.current_user) do
21
      socket =
22
        socket
23
        |> assign(:page_title, "Operator · Tokens")
24
        |> assign(:status, :loading)
25
        |> assign(:report, nil)
26
27
      if connected?(socket) do
28
        send(self(), :load)
29
      end
30
31
      {:ok, socket}
32
    else
33
      {:ok, redirect(socket, to: ~p"/")}
34
    end
35
  end
36
37
  @impl true
38
  def handle_event("refresh", _params, socket) do
39
    # Re-checked per event, not only at mount: a long-lived socket outlives the
40
    # decision that opened it.
41
    if Accounts.admin?(socket.assigns.current_user) do
42
      {:noreply,
43
       socket
44
       |> assign(:status, :loading)
45
       |> load()}
46
    else
47
      {:noreply, redirect(socket, to: ~p"/")}
48
    end
49
  end
50
51
  @impl true
52
  def handle_info(:load, socket), do: {:noreply, load(socket)}
53
54
  defp load(%{assigns: %{status: :loading}} = socket) do
55
    assign(socket, status: :loaded, report: TokenProductivity.report())
56
  end
57
58
  # A refresh while a load is already resolving must not clobber the newer
59
  # state with an older response.
60
  defp load(socket), do: socket
61
62
  @impl true
63
  def render(assigns) do
64
    ~H"""
65
    <Layouts.app
66
      flash={@flash}
67
      sidebar_sections={assigns[:sidebar_sections]}
68
      current_scope={@current_scope}
69
      title="Token productivity"
70
    >
71
      <main id="admin-tokens-page" class="app-shell admin-shell">
72
        <section class="admin space-y-8" aria-labelledby="tokens-heading">
73
          <header class="admin-heading">
74
            <h1 id="tokens-heading">Token productivity</h1>
75
            <p>
76
              Raw token volume next to the tokens that produced durable outcomes —
77
              merged work, closed issues, and verified receipts. Aggregate counts
78
              only; no conversation or run content is readable from this page.
79
            </p>
80
            <div class="admin-totals">
81
              <.badge
82
                :if={@status == :loaded && @report}
83
                variant={:dim}
84
                id="tokens-generated-at"
85
              >
86
                GENERATED {Calendar.strftime(@report.generated_at, "%Y-%m-%d %H:%M UTC")}
87
              </.badge>
88
              <.text_button id="tokens-refresh" phx-click="refresh" disabled={@status == :loading}>
89
                {if(@status == :loading, do: "REFRESHING…", else: "REFRESH")}
90
              </.text_button>
91
            </div>
92
          </header>
93
94
          <.alert :if={@status == :loading} id="tokens-loading" appearance={:row}>
95
            Computing token totals…
96
          </.alert>
97
98
          <div :if={@status == :loaded && @report} class="space-y-8">
99
            <section aria-labelledby="productive-heading">
100
              <.card id="tokens-productive">
101
                <h2 id="productive-heading" class="card-title">Productive versus raw</h2>
102
                <div class="flex flex-wrap items-center gap-3 pb-3">
103
                  <.badge>{format_tokens(@report.raw.total_tokens)} RAW</.badge>
104
                  <.badge variant={:success}>
105
                    {format_tokens(@report.productive.total_tokens)} PRODUCTIVE
106
                  </.badge>
107
                  <.badge :if={@report.productive.share} variant={:info}>
108
                    {format_share(@report.productive.share)} PRODUCTIVE SHARE
109
                  </.badge>
110
                </div>
111
                <ul class="divide-y divide-border">
112
                  <li class="flex items-center justify-between gap-4 py-3">
113
                    <span>Merged work</span>
114
                    <span class="font-semibold">
115
                      {format_tokens(@report.productive.merged_work.total_tokens)}
116
                    </span>
117
                  </li>
118
                  <li class="flex items-center justify-between gap-4 py-3">
119
                    <span>Closed issues</span>
120
                    <span class="font-semibold">
121
                      {format_tokens(@report.productive.closed_issues.total_tokens)}
122
                    </span>
123
                  </li>
124
                  <li class="flex items-center justify-between gap-4 py-3">
125
                    <span>Verified receipts</span>
126
                    <span class="font-semibold">
127
                      {format_tokens(@report.productive.verified_receipts.total_tokens)}
128
                    </span>
129
                  </li>
130
                </ul>
131
              </.card>
132
            </section>
133
134
            <section aria-labelledby="rates-heading">
135
              <.card id="tokens-rates">
136
                <h2 id="rates-heading" class="card-title">Cache and split</h2>
137
                <ul class="divide-y divide-border">
138
                  <li class="flex items-center justify-between gap-4 py-3">
139
                    <span>Cache hit rate</span>
140
                    <span class="font-semibold">{format_share(@report.cache.hit_rate)}</span>
141
                  </li>
142
                  <li class="flex items-center justify-between gap-4 py-3">
143
                    <span>Cached input tokens</span>
144
                    <span class="font-semibold">
145
                      {format_tokens(@report.cache.cached_input_tokens)}
146
                    </span>
147
                  </li>
148
                  <li class="flex items-center justify-between gap-4 py-3">
149
                    <span>Input share</span>
150
                    <span class="font-semibold">{format_share(@report.split.input_share)}</span>
151
                  </li>
152
                  <li class="flex items-center justify-between gap-4 py-3">
153
                    <span>Input / output tokens</span>
154
                    <span class="font-semibold">
155
                      {format_tokens(@report.split.input_tokens)} / {format_tokens(
156
                        @report.split.output_tokens
157
                      )}
158
                    </span>
159
                  </li>
160
                </ul>
161
              </.card>
162
            </section>
163
164
            <section aria-labelledby="sources-heading">
165
              <.card id="tokens-sources">
166
                <h2 id="sources-heading" class="card-title">Raw volume by source</h2>
167
                <.table id="tokens-sources-table" rows={source_rows(@report.sources)}>
168
                  <:col :let={row} label="Source">{row.label}</:col>
169
                  <:col :let={row} label="Input">{format_tokens(row.totals.input_tokens)}</:col>
170
                  <:col :let={row} label="Output">{format_tokens(row.totals.output_tokens)}</:col>
171
                  <:col :let={row} label="Total">{format_tokens(row.totals.total_tokens)}</:col>
172
                </.table>
173
              </.card>
174
            </section>
175
176
            <section aria-labelledby="providers-heading">
177
              <.card id="tokens-providers">
178
                <h2 id="providers-heading" class="card-title">Provider throughput</h2>
179
                <p :if={@report.providers == []} class="text-muted-foreground">
180
                  No completed provider steps recorded yet.
181
                </p>
182
                <.table
183
                  :if={@report.providers != []}
184
                  id="tokens-providers-table"
185
                  rows={@report.providers}
186
                >
187
                  <:col :let={row} label="Provider">{row.provider_id}</:col>
188
                  <:col :let={row} label="Steps">{row.steps}</:col>
189
                  <:col :let={row} label="Input">{format_tokens(row.input_tokens)}</:col>
190
                  <:col :let={row} label="Output">{format_tokens(row.output_tokens)}</:col>
191
                  <:col :let={row} label="Tokens/s">{format_rate(row.tokens_per_second)}</:col>
192
                </.table>
193
              </.card>
194
            </section>
195
          </div>
196
        </section>
197
      </main>
198
    </Layouts.app>
199
    """
200
  end
201
202
  defp source_rows(sources) do
203
    [
204
      %{label: "Typed turns", totals: sources.typed_turns},
205
      %{label: "Voice sessions", totals: sources.voice_sessions},
206
      %{label: "Work jobs", totals: sources.work_jobs},
207
      %{label: "SCV runs", totals: sources.scv_runs}
208
    ]
209
  end
210
211
  defp format_tokens(count) when is_integer(count) do
212
    count
213
    |> Integer.to_charlist()
214
    |> Enum.reverse()
215
    |> Enum.chunk_every(3)
216
    |> Enum.join(",")
217
    |> String.reverse()
218
  end
219
220
  defp format_share(nil), do: "n/a"
221
222
  defp format_share(share) when is_float(share),
223
    do: "#{:erlang.float_to_binary(share * 100, decimals: 1)}%"
224
225
  defp format_rate(nil), do: "n/a"
226
227
  defp format_rate(rate) when is_float(rate),
228
    do: :erlang.float_to_binary(rate, decimals: 1)
229
end
lib/openagents_web/route_authority.ex modified +3

@@ -138,6 +138,9 @@ defmodule OpenAgentsWeb.RouteAuthority do

138 138
  defp policy(%{path: "/admin/analytics"}),
139 139
    do: declaration(:operator, "configured operator GitHub ID", "analytics:read", false)
140 140
141
  defp policy(%{path: "/admin/tokens"}),
142
    do: declaration(:operator, "configured operator GitHub ID", "tokens:productivity:read", false)
143
141 144
  defp policy(%{path: "/admin/forge"}),
142 145
    do: declaration(:operator, "configured operator GitHub ID", "forge:promote", true)
143 146
lib/openagents_web/router.ex modified +1

@@ -221,6 +221,7 @@ defmodule OpenAgentsWeb.Router do

221 221
      ] do
222 222
      live "/", AdminLive, :index
223 223
      live "/analytics", AdminAnalyticsLive, :index
224
      live "/tokens", AdminTokensLive, :index
224 225
      live "/forge", AdminForgeLive, :index
225 226
      live "/recordings", AdminRecordingsLive, :index
226 227
      live "/scv/accounts", AdminScvAccountsLive, :index
test/openagents/token_productivity_test.exs added +313

@@ -0,0 +1,313 @@

1
defmodule OpenAgents.TokenProductivityTest do
2
  @moduledoc """
3
  Productive attribution is exclusive bucketing over durable outcome evidence,
4
  so the tests that matter are the boundaries: which bucket a run lands in,
5
  what never counts as productive, and that provider steps stay out of the raw
6
  totals they would double-count.
7
  """
8
9
  use OpenAgents.DataCase, async: false
10
11
  import OpenAgents.IssuesFixtures
12
13
  alias OpenAgents.Accounts
14
  alias OpenAgents.Context.Composer
15
  alias OpenAgents.Conversations
16
  alias OpenAgents.Conversations.ProviderStep
17
  alias OpenAgents.Issues
18
  alias OpenAgents.Providers.Request
19
  alias OpenAgents.PullRequests.PullRequest
20
  alias OpenAgents.SCV.DriverAccount
21
  alias OpenAgents.SCV.Execution
22
  alias OpenAgents.TokenProductivity
23
  alias OpenAgents.Work.Job
24
25
  test "sums raw volume by source without reading provider steps" do
26
    user = account("raw-sources")
27
    conversation = conversation_for(user)
28
29
    %{turn: turn, receipt: receipt} = begin_typed_turn(conversation, "Raw volume turn.")
30
31
    # The provider step carries its own copy of the same tokens the receipt
32
    # merges; counting both would double the raw total.
33
    {:ok, _step} =
34
      Conversations.record_provider_step_completion(receipt, "response-raw", %{
35
        "input_tokens" => 100,
36
        "output_tokens" => 40,
37
        "total_tokens" => 140
38
      })
39
40
    {:ok, _turn} =
41
      Conversations.complete_turn(turn, "response-raw", %{
42
        "input_tokens" => 100,
43
        "output_tokens" => 40,
44
        "total_tokens" => 140
45
      })
46
47
    insert_job(conversation, "completed", %{
48
      "input_tokens" => 30,
49
      "output_tokens" => 10,
50
      "total_tokens" => 40
51
    })
52
53
    account = driver_account("raw-sources-driver")
54
55
    insert_run(account, 1, "failed", nil, %{
56
      "input_tokens" => 8,
57
      "output_tokens" => 2,
58
      "total_tokens" => 10
59
    })
60
61
    report = TokenProductivity.report()
62
63
    assert report.sources.typed_turns.total_tokens == 140
64
    assert report.sources.work_jobs.total_tokens == 40
65
    assert report.sources.scv_runs.total_tokens == 10
66
    assert report.sources.voice_sessions.total_tokens == 0
67
    assert report.raw.total_tokens == 190
68
    assert report.raw.input_tokens == 138
69
    assert report.raw.output_tokens == 52
70
  end
71
72
  test "buckets productive tokens by strongest outcome evidence, each row once" do
73
    repository = repository_fixture()
74
    account = driver_account("productive-driver")
75
76
    # Merged work: the run's issue carries a merged pull request.
77
    merged_issue = issue_fixture(repository, %{title: "merged work"})
78
    insert_merged_pull_request(repository, merged_issue)
79
80
    insert_run(account, 1, "succeeded", merged_issue.id, %{
81
      "input_tokens" => 70,
82
      "output_tokens" => 30,
83
      "total_tokens" => 100
84
    })
85
86
    # Closed issue: closed without a merged pull request.
87
    closed_issue = issue_fixture(repository, %{title: "closed issue"})
88
    {:ok, _closed} = Issues.update_issue(closed_issue, %{"state" => "closed"})
89
90
    insert_run(account, 2, "succeeded", closed_issue.id, %{
91
      "input_tokens" => 14,
92
      "output_tokens" => 6,
93
      "total_tokens" => 20
94
    })
95
96
    # Verified receipt: succeeded with its terminal receipt, no stronger
97
    # evidence.
98
    insert_run(account, 3, "succeeded", nil, %{
99
      "input_tokens" => 7,
100
      "output_tokens" => 3,
101
      "total_tokens" => 10
102
    })
103
104
    # A failed run is raw volume only.
105
    insert_run(account, 4, "failed", nil, %{
106
      "input_tokens" => 300,
107
      "output_tokens" => 100,
108
      "total_tokens" => 400
109
    })
110
111
    # Completed work jobs carry their bounded report; failed ones count as raw
112
    # volume only.
113
    conversation = conversation_for(account("productive-jobs"))
114
    insert_job(conversation, "completed", %{"input_tokens" => 4, "output_tokens" => 1})
115
    insert_job(conversation, "failed", %{"input_tokens" => 900, "output_tokens" => 100})
116
117
    report = TokenProductivity.report()
118
119
    assert report.productive.merged_work.total_tokens == 100
120
    assert report.productive.closed_issues.total_tokens == 20
121
    assert report.productive.verified_receipts.total_tokens == 15
122
    assert report.productive.total_tokens == 135
123
    assert report.raw.total_tokens == 1535
124
    assert_in_delta report.productive.share, 135 / 1535, 0.000001
125
  end
126
127
  test "cache hit rate spans inclusive and exclusive cached-token spellings" do
128
    user = account("cache-rate")
129
130
    # Typed turns count cached tokens inside input_tokens.
131
    complete_typed_turn(user, "Cached typed turn.", %{
132
      "input_tokens" => 80,
133
      "output_tokens" => 20,
134
      "total_tokens" => 100,
135
      "cached_input_tokens" => 60
136
    })
137
138
    # OpenCode-style usage counts cache reads outside input_tokens.
139
    account = driver_account("cache-rate-driver")
140
141
    insert_run(account, 1, "succeeded", nil, %{
142
      "input_tokens" => 10,
143
      "output_tokens" => 5,
144
      "total_tokens" => 15,
145
      "cache_read_tokens" => 110
146
    })
147
148
    report = TokenProductivity.report()
149
150
    assert report.cache.cached_input_tokens == 170
151
    assert report.cache.input_tokens == 200
152
    assert_in_delta report.cache.hit_rate, 170 / 200, 0.000001
153
    assert_in_delta report.split.input_share, 200 / 225, 0.000001
154
  end
155
156
  test "provider throughput reads completed steps grouped by provider" do
157
    user = account("provider-throughput")
158
    conversation = conversation_for(user)
159
160
    # begin_typed_turn records a "started" step at sequence 1; it must stay
161
    # invisible to the throughput table.
162
    %{receipt: receipt} = begin_typed_turn(conversation, "Throughput turn.")
163
164
    Repo.insert!(%ProviderStep{
165
      turn_receipt_id: receipt.id,
166
      sequence: 2,
167
      provider_id: "test.provider",
168
      model_id: "model-v1",
169
      status: "completed",
170
      provider_response_id: "response-throughput",
171
      usage: %{
172
        "input_tokens" => 980,
173
        "output_tokens" => 260,
174
        "total_tokens" => 1240,
175
        "cached_input_tokens" => 850
176
      },
177
      started_at: ~U[2026-08-20 12:00:00.000000Z],
178
      completed_at: ~U[2026-08-20 12:00:10.000000Z]
179
    })
180
181
    assert [row] = TokenProductivity.providers()
182
    assert row.provider_id == "test.provider"
183
    assert row.steps == 1
184
    assert row.input_tokens == 980
185
    assert row.output_tokens == 260
186
    assert row.cached_input_tokens == 850
187
    assert row.total_tokens == 1240
188
    assert row.duration_ms == 10_000
189
    assert_in_delta row.tokens_per_second, 26.0, 0.000001
190
  end
191
192
  test "an empty database reports zeros and no rates" do
193
    report = TokenProductivity.report()
194
195
    assert report.raw.total_tokens == 0
196
    assert report.productive.total_tokens == 0
197
    assert report.productive.share == nil
198
    assert report.cache.hit_rate == nil
199
    assert report.split.input_share == nil
200
    assert report.providers == []
201
  end
202
203
  defp account(key) do
204
    digest = :crypto.hash(:sha256, key)
205
    github_id = digest |> binary_part(0, 7) |> :binary.decode_unsigned()
206
    suffix = digest |> Base.encode16(case: :lower) |> binary_part(0, 12)
207
208
    {:ok, user} =
209
      Accounts.upsert_github_user(%{
210
        github_id: github_id,
211
        github_login: "test-#{suffix}",
212
        github_avatar_url: "https://avatars.githubusercontent.com/u/#{github_id}?v=4"
213
      })
214
215
    user
216
  end
217
218
  defp conversation_for(user) do
219
    {:ok, conversation} = Conversations.ensure_conversation(user)
220
    conversation
221
  end
222
223
  defp complete_typed_turn(user, content, usage) do
224
    conversation = conversation_for(user)
225
    %{turn: turn} = begin_typed_turn(conversation, content)
226
    {:ok, _turn} = Conversations.complete_turn(turn, "response-#{turn.id}", usage)
227
    :ok
228
  end
229
230
  defp begin_typed_turn(conversation, content) do
231
    {:ok, records} = Conversations.create_turn(conversation, content)
232
    context = Composer.compose!()
233
    messages = Conversations.provider_messages(conversation.id)
234
235
    request = %Request{
236
      model_id: "model-v1",
237
      instructions: context.instructions,
238
      input: messages
239
    }
240
241
    {:ok, inference} =
242
      Conversations.begin_inference(records.turn, context, request, "test.provider", [])
243
244
    inference
245
  end
246
247
  defp driver_account(label) do
248
    operator = account("operator-#{label}")
249
250
    %DriverAccount{}
251
    |> DriverAccount.create_changeset(%{
252
      operator_id: operator.id,
253
      label: label,
254
      secret_ref: "file:#{label}-#{System.unique_integer([:positive])}"
255
    })
256
    |> Repo.insert!()
257
  end
258
259
  defp insert_run(%DriverAccount{} = account, generation, status, issue_id, usage) do
260
    now = DateTime.utc_now()
261
    report = "Run receipt for generation #{generation}."
262
263
    Repo.insert!(%Execution{
264
      driver_account_id: account.id,
265
      issue_id: issue_id,
266
      driver: "codex_app_server",
267
      principal: "scv:codex_app_server:#{account.id}",
268
      repository_revision: String.duplicate("a", 40),
269
      objective: "Bounded test objective.",
270
      permission_profile: "read_only",
271
      model: "gpt-5.6-luna",
272
      reasoning_effort: "low",
273
      status: status,
274
      owner_node: "test@node",
275
      generation: generation,
276
      lease_expires_at: now,
277
      report: report,
278
      report_digest: "sha256:" <> (:crypto.hash(:sha256, report) |> Base.encode16(case: :lower)),
279
      usage: usage,
280
      started_at: now,
281
      completed_at: now
282
    })
283
  end
284
285
  defp insert_job(conversation, status, usage) do
286
    Repo.insert!(%Job{
287
      conversation_id: conversation.id,
288
      owner_visitor_id: conversation.visitor_id,
289
      surface: "text",
290
      goal: "Bounded test goal.",
291
      status: status,
292
      report: "Terminal report.",
293
      usage: usage,
294
      started_at: DateTime.utc_now(),
295
      completed_at: DateTime.utc_now()
296
    })
297
  end
298
299
  defp insert_merged_pull_request(repository, issue) do
300
    Repo.insert!(%PullRequest{
301
      repository_id: repository.id,
302
      issue_id: issue.id,
303
      head_repository_id: repository.id,
304
      head_ref: "scv/merged-work",
305
      head_sha: String.duplicate("b", 40),
306
      base_ref: "main",
307
      base_sha: String.duplicate("c", 40),
308
      state: "closed",
309
      draft: false,
310
      merged_at: DateTime.utc_now()
311
    })
312
  end
313
end
test/openagents_web/live/admin_tokens_live_test.exs added +58

@@ -0,0 +1,58 @@

1
defmodule OpenAgentsWeb.AdminTokensLiveTest do
2
  @moduledoc """
3
  `/admin/tokens` gates like every operator surface and renders aggregate
4
  token counts and rates only. Nothing on the page names conversation, run,
5
  or report content.
6
  """
7
8
  use OpenAgentsWeb.ConnCase, async: false
9
10
  import Phoenix.LiveViewTest
11
12
  describe "access" do
13
    test "the operator reaches the surface", %{conn: conn} do
14
      conn = log_in_admin_user(conn, "tokens-operator")
15
16
      {:ok, _view, html} = live(conn, ~p"/admin/tokens")
17
18
      assert html =~ "Token productivity"
19
    end
20
21
    test "an ordinary authenticated account is redirected and told nothing", %{conn: conn} do
22
      conn = log_in_github_user(conn, "tokens-ordinary")
23
24
      assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/admin/tokens")
25
26
      response = get(conn, ~p"/admin/tokens")
27
      assert redirected_to(response) == ~p"/"
28
    end
29
30
    test "an unauthenticated visitor is redirected", %{conn: conn} do
31
      assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/admin/tokens")
32
    end
33
  end
34
35
  describe "report" do
36
    test "renders the aggregate sections and refreshes", %{conn: conn} do
37
      conn = log_in_admin_user(conn, "tokens-loaded")
38
39
      {:ok, view, _html} = live(conn, ~p"/admin/tokens")
40
      render(view)
41
42
      assert has_element?(view, "#tokens-productive")
43
      assert has_element?(view, "#tokens-rates")
44
      assert has_element?(view, "#tokens-sources-table")
45
      assert has_element?(view, "#tokens-generated-at")
46
47
      html = render(view)
48
      assert html =~ "Merged work"
49
      assert html =~ "Closed issues"
50
      assert html =~ "Verified receipts"
51
      assert html =~ "Cache hit rate"
52
      assert html =~ "Input share"
53
54
      assert view |> element("#tokens-refresh") |> render_click() =~ "Token productivity"
55
      assert has_element?(view, "#tokens-generated-at")
56
    end
57
  end
58
end

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