repl: :cost meta surface (Phase 7 commit #5)

User-facing reporter of the per-session accumulator. Three shapes:

  :cost            one-line summary (calls / tokens / cost)
  :cost detail     per-model + per-category breakdown
  :cost reset      zero the meter; clears warn flags

All read-only against ctx.usage_totals; no broker calls.

R6 — annotation uses the per-slot is_local sticky flag, NOT a fragile
cost==0 heuristic. Summary line classifies:

  cloud only -> "cost=$X.XXXXXX"
  cloud + local mix -> "cost=$X.XXXXXX (cloud only; local: tokens
                       but no cost field)"
  local only -> "cost=$X.XXXXXX (local only; no cost field)"

R7 — :cost detail rows sort by (cost desc, model asc, category asc).
Three-level key for deterministic output across equal-cost rows
(table.sort is unstable; identical costs would otherwise reorder).

R10 — all dollar values use $%.6f formatting. Sub-cent precision is
critical: a Haiku call can cost $0.000028; $%.4f would round it to
$0.0000 — indistinguishable from local $0.

Column width widened to %-26s to fit fully-qualified cloud model
names (e.g. "anthropic/claude-haiku-4.5" = 25 chars).

E2E verified against live cloud + local broker:

  :cost (empty session)          -> "0 calls, $0.000000"
  ...after mixed-mode session...
  :cost                          -> "5 calls, prompt=472 / completion=26
                                     tokens, cost=$0.000377 (cloud only;
                                     local: tokens but no cost field)"
  :cost detail                   -> 4 rows: main cloud $0.000219, probe
                                     cloud $0.000128, delegate cloud
                                     $0.000030, main local $0.000000
                                     (local). Sort by cost desc within
                                     model.
  :cost reset                    -> "cost meter reset"; subsequent
                                     :cost shows zeros.

All 5 categories appeared in the same session: main (twice — cloud
+ local), delegate, probe (x2 from :safety check). Warn-threshold
firing already verified in commit #3 + #4.

HELP gains 3 :cost lines.

Regression: test_safety 87/87, test_router_model 31/31, repl loads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 23:02:24 +00:00
parent b30212af0f
commit 0d6ff93134
+75
View File
@@ -185,6 +185,9 @@ Meta commands:
:tree off clear the [project] block :tree off clear the [project] block
:diff [<git-args>] git diff <args> -> inject as [diff ...] exec_output :diff [<git-args>] git diff <args> -> inject as [diff ...] exec_output
examples: :diff :diff --cached :diff main..feature examples: :diff :diff --cached :diff main..feature
:cost summary of session token/cost usage
:cost detail per-model + per-category breakdown
:cost reset zero the cost meter (also clears warn flags)
:highlight [on|off|status] :highlight [on|off|status]
toggle tree-sitter syntax highlighting on assistant toggle tree-sitter syntax highlighting on assistant
code fences (requires external tree-sitter CLI + code fences (requires external tree-sitter CLI +
@@ -2024,6 +2027,78 @@ function M.run(config)
-- Phase 6: :diff meta — `git diff <args>` (B1-clean), appends as -- Phase 6: :diff meta — `git diff <args>` (B1-clean), appends as
-- [diff <args>]\n<output> exec_output. Reads cwd at invocation -- [diff <args>]\n<output> exec_output. Reads cwd at invocation
-- time (R6: differs from :tree's scan-time cwd capture). Empty -- time (R6: differs from :tree's scan-time cwd capture). Empty
-- Phase 7: :cost meta — read-only reporter of ctx.usage_totals.
-- :cost summary line
-- :cost detail per-model + per-category breakdown
-- :cost reset zero the meter (also clears warn flags)
-- R7 sort: (cost desc, model asc, category asc) — table.sort is
-- unstable, so the 3-level key ensures deterministic output.
-- R10: $%.6f for sub-cent precision (cloud costs can be ~3e-05).
-- R6: annotation uses the per-slot is_local sticky flag rather
-- than a fragile cost==0 heuristic.
meta.cost = function(args)
local sub = ((args or ""):match("^%s*(%S*)") or ""):lower()
if sub == "reset" then
ctx:reset_usage()
renderer.status("cost meter reset")
return
end
local total_cost = ctx:total_cost()
local total_p, total_c = ctx:total_tokens()
local has_local, has_cloud = false, false
for _, m in pairs(ctx.usage_totals or {}) do
for _, c in pairs(m) do
if c.is_local then has_local = true end
if c.cost > 0 then has_cloud = true end
end
end
local label
if has_local and has_cloud then
label = "(cloud only; local: tokens but no cost field)"
elseif has_local and not has_cloud then
label = "(local only; no cost field)"
else
label = ""
end
if sub == "" then
local calls = 0
for _, m in pairs(ctx.usage_totals or {}) do
for _, c in pairs(m) do calls = calls + c.calls end
end
renderer.status(("session usage: %d calls, prompt=%d / completion=%d tokens, cost=$%.6f %s"):format(
calls, total_p, total_c, total_cost, label))
return
end
if sub == "detail" then
local rows = {}
for model, cats in pairs(ctx.usage_totals or {}) do
for category, c in pairs(cats) do
rows[#rows + 1] = {
model = model, category = category,
prompt = c.prompt, completion = c.completion,
calls = c.calls, cost = c.cost,
is_local = c.is_local,
}
end
end
if #rows == 0 then renderer.status("(no usage recorded)"); return end
-- R7: 3-level deterministic sort
table.sort(rows, function(a, b)
if a.cost ~= b.cost then return a.cost > b.cost end
if a.model ~= b.model then return a.model < b.model end
return a.category < b.category
end)
renderer.status(("session usage detail (total=$%.6f, %d/%d tokens):"):format(
total_cost, total_p, total_c))
for _, r in ipairs(rows) do
io.write((" %-26s %-18s %3d calls, %6d / %6d tokens, $%.6f%s\n"):format(
r.model, r.category, r.calls, r.prompt, r.completion, r.cost,
r.is_local and " (local)" or ""))
end
return
end
renderer.status("usage: :cost [detail|reset]")
end
-- diff or git failure emits status and skips — never pollutes -- diff or git failure emits status and skips — never pollutes
-- context with empty or error noise. -- context with empty or error noise.
meta.diff = function(args) meta.diff = function(args)