Files
state-hub/dashboard/src/token-cost.md
tegwick acb30978cd feat(token-tracking): repo aggregation via graph walk (task→workstream→repo)
By Repo now resolves via the full chain rather than requiring repo_id
directly on the token event:
  1. token_events.repo_id (direct)
  2. → workstreams.repo_id (via workstream_id)
  3. → task.workstream_id → workstreams.repo_id (via task_id)

Changes:
- Auto-populate repo_id on token events at creation time (both the
  token_events router and the tasks router)
- New GET /token-events/by-repo/ endpoint with RepoTokenSummary schema;
  returns tokens_in/out/total, event_count, by_model, by_note per repo
- Dashboard By Repo section uses /by-repo/ directly and shows repo_slug
  instead of a truncated UUID
- Backfilled the three existing events (userbased) with repo_id via SQL

185 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:05:23 +02:00

4.9 KiB

title
title
Token Cost
import {API} from "./components/config.js";
const POLL = 60_000;
// Fetch both /by-repo/ and raw events in parallel
const tokenState = (async function*() {
  while (true) {
    let byRepo = [], events = [], ok = false;
    try {
      const [r1, r2] = await Promise.all([
        fetch(`${API}/token-events/by-repo/`),
        fetch(`${API}/token-events/?limit=1000`),
      ]);
      ok = r1.ok && r2.ok;
      if (ok) {
        byRepo = await r1.json();
        events = await r2.json();
      }
    } catch {}
    yield {byRepo, events, ok, ts: new Date()};
    await new Promise(res => setTimeout(res, POLL));
  }
})();
function buildSummary(events) {
  const byWs = {}, byModel = {}, byTask = {};
  for (const e of events) {
    const tot = (e.tokens_in || 0) + (e.tokens_out || 0);
    if (e.workstream_id) {
      byWs[e.workstream_id] = byWs[e.workstream_id] || {scope_id: e.workstream_id, tokens_in: 0, tokens_out: 0, event_count: 0};
      byWs[e.workstream_id].tokens_in += e.tokens_in || 0;
      byWs[e.workstream_id].tokens_out += e.tokens_out || 0;
      byWs[e.workstream_id].event_count++;
    }
    const model = e.model || "unknown";
    byModel[model] = (byModel[model] || 0) + tot;
    if (e.task_id) {
      byTask[e.task_id] = byTask[e.task_id] || {task_id: e.task_id, tokens_in: 0, tokens_out: 0};
      byTask[e.task_id].tokens_in += e.tokens_in || 0;
      byTask[e.task_id].tokens_out += e.tokens_out || 0;
    }
  }
  const sortDesc = obj => Object.entries(obj)
    .map(([k,v]) => typeof v === "number" ? {id: k, tokens_total: v} : {...v, tokens_total: (v.tokens_in||0)+(v.tokens_out||0)})
    .sort((a,b) => b.tokens_total - a.tokens_total);
  return {
    by_workstream: sortDesc(byWs),
    by_model: Object.entries(byModel).map(([model,tokens_total]) => ({model,tokens_total})).sort((a,b)=>b.tokens_total-a.tokens_total),
    top_tasks: sortDesc(byTask).slice(0,10),
    total_events: events.length,
  };
}
const byRepo = tokenState.byRepo ?? [];
const summary = buildSummary(tokenState.events ?? []);
const _ok = tokenState.ok ?? false;
const _ts = tokenState.ts;

Token Cost

const _liveEl = html`<div style="font-size:0.8rem;color:${_ok?'var(--theme-foreground-focus)':'red'}">
${_ok ? `Live · ${_ts?.toLocaleTimeString()} · ${summary.total_events} events` : "API offline"}
</div>`;
display(_liveEl);

By Repo

if (byRepo.length === 0) {
  display(html`<p style="color:var(--theme-foreground-muted)">No token events with repo association yet.</p>`);
} else {
  display(Plot.plot({
    title: "Token consumption by repo",
    marginLeft: 160,
    width: Math.min(900, width),
    x: {label: "Tokens", tickFormat: "~s"},
    y: {label: null},
    color: {legend: true, domain: ["tokens_in", "tokens_out"], range: ["#4e79a7","#f28e2b"]},
    marks: [
      Plot.barX(
        byRepo.flatMap(r => [
          {repo: r.repo_slug, type: "tokens_in",  value: r.tokens_in},
          {repo: r.repo_slug, type: "tokens_out", value: r.tokens_out},
        ]),
        {x: "value", y: "repo", fill: "type", tip: true}
      ),
    ],
  }));
}

By Workplan

const wsRows = summary.by_workstream.slice(0, 20);
if (wsRows.length === 0) {
  display(html`<p style="color:var(--theme-foreground-muted)">No workstream data yet.</p>`);
} else {
  display(Inputs.table(wsRows, {
    columns: ["scope_id", "tokens_in", "tokens_out", "tokens_total", "event_count"],
    header: {
      scope_id: "Workstream ID",
      tokens_in: "Tokens In",
      tokens_out: "Tokens Out",
      tokens_total: "Total",
      event_count: "Events",
    },
    format: {
      scope_id: d => d.slice(0,8) + "…",
      tokens_in: d => d.toLocaleString(),
      tokens_out: d => d.toLocaleString(),
      tokens_total: d => d.toLocaleString(),
    },
    width: {scope_id: 120, tokens_in: 110, tokens_out: 110, tokens_total: 110, event_count: 80},
  }));
}

By Model

if (summary.by_model.length === 0) {
  display(html`<p style="color:var(--theme-foreground-muted)">No model data yet.</p>`);
} else {
  display(Plot.plot({
    title: "Token consumption by model",
    marginLeft: 200,
    width: Math.min(700, width),
    x: {label: "Total tokens", tickFormat: "~s"},
    marks: [
      Plot.barX(summary.by_model, {x: "tokens_total", y: "model", fill: "#4e79a7", tip: true}),
    ],
  }));
}

Top 10 Tasks by Tokens

if (summary.top_tasks.length === 0) {
  display(html`<p style="color:var(--theme-foreground-muted)">No task-level data yet.</p>`);
} else {
  display(Inputs.table(summary.top_tasks, {
    columns: ["task_id", "tokens_in", "tokens_out", "tokens_total"],
    header: {task_id: "Task ID", tokens_in: "In", tokens_out: "Out", tokens_total: "Total"},
    format: {
      task_id: d => d.slice(0,8) + "…",
      tokens_in: d => d.toLocaleString(),
      tokens_out: d => d.toLocaleString(),
      tokens_total: d => d.toLocaleString(),
    },
  }));
}