Public: anyone can read this; the crew can change it.
Tokens as a unit people can feel: files, runs, issues and pulls carry a token count the way storage carries MB and GB #554
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/403-tokens-as-a-unit-people-can-feel-files-r → main 8 commits fork
17 files
changed +655
−18
docs/DESIGN.md +12
| @@ -93,6 +93,16 @@ Rules: | ||
| 93 | 93 | and descriptions. |
| 94 | 94 | - **Truncation is honest**: one line, ellipsis, and a `title` with the whole |
| 95 | 95 | string. Never a fade-out mask — masks lie about whether there is more. |
| 96 | - **A token count is shown where a byte size is shown, never as a badge on | |
| 97 | its own.** Tokens are a unit, like KB: they sit beside a file's size, a | |
| 98 | directory's total, a run's cost or a window's dollars and percentage, so | |
| 99 | the context intuition grows next to the storage and money ones. One | |
| 100 | formatter (`kernel/tokens-format.ts`, rendered by `tokenCount()` / | |
| 101 | `sizeWithTokens()` in `web/ui.ts`) writes them all: `850 tok`, `1.2k tok`, | |
| 102 | `48k tok`, `1.1M tok`. A count the provider reported is bare; an estimate | |
| 103 | (bytes ÷ a stated bytes-per-token ratio for prose, code or data) carries a | |
| 104 | `~`, and every count's `title` says which tokenizer and whether it is an | |
| 105 | estimate. A binary file has no token count — no number beats a wrong one. | |
| 96 | 106 | |
| 97 | 107 | --- |
| 98 | 108 | |
| @@ -1123,3 +1133,5 @@ deletes a repository. | ||
| 1123 | 1133 | 9. Nothing inside a linked row is itself a link. |
| 1124 | 1134 | 10. Any control that only works with a script carries `js-only`, so a page |
| 1125 | 1135 | without JavaScript never shows a dead button. |
| 1136 | 11. Every token count sits beside a byte size, a cost or a percentage, and | |
| 1137 | goes through `tokenCount()` — never a hand-written `k`. |
forge.json +1 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | "ready": 90 |
| 7 | 7 | }, |
| 8 | 8 | "check": { |
| 9 | "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 -- crew review-route review-lines review-gate crew-wrapup crew-limits crew-parks crew-sandbox steer session-settle landing decide ledger checks dependencies map-closing autonomy studio studio-words forks fork-flow cross-repo-pull pulls apps apps-adversarial apps-busy apps-token surfaces-adversarial orgs web git-trust helpers shards migrate vitals guard asks feed feed-page offline share cloud run-secrets forge-cloudflare usage credentials standing board board-windows spawn-plan spawn web-apps fleet-ops meter told-by", | |
| 9 | "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 -- crew review-route review-lines review-gate crew-wrapup crew-limits crew-parks crew-sandbox steer session-settle landing decide ledger checks dependencies map-closing autonomy studio studio-words forks fork-flow cross-repo-pull pulls apps apps-adversarial apps-busy apps-token surfaces-adversarial orgs web git-trust helpers shards migrate vitals guard asks feed feed-page offline share cloud run-secrets forge-cloudflare usage tokens credentials standing board board-windows spawn-plan spawn web-apps fleet-ops meter told-by", | |
| 10 | 10 | "timeout": 1800 |
| 11 | 11 | } |
| 12 | 12 | } |
src/core/usage.ts +28
| @@ -126,6 +126,34 @@ export function getUsage(db: Database, runId: string): UsageEntry | undefined { | ||
| 126 | 126 | return row ? toEntry(row) : undefined; |
| 127 | 127 | } |
| 128 | 128 | |
| 129 | /** | |
| 130 | * Everything the runs on one issue or pull spent, over all time, or | |
| 131 | * `undefined` when none has landed in the ledger. One aggregate over the | |
| 132 | * table: the ledger is a row per run, small beside the run table itself. | |
| 133 | */ | |
| 134 | export function issueUsage( | |
| 135 | db: Database, | |
| 136 | issueId: string, | |
| 137 | ): { runs: number; tokensIn: number; tokensOut: number; costUsd: number } | undefined { | |
| 138 | const row = db | |
| 139 | .query< | |
| 140 | { runs: number; tokens_in: number; tokens_out: number; cost_usd: number }, | |
| 141 | [string] | |
| 142 | >( | |
| 143 | `SELECT COUNT(*) AS runs, SUM(tokens_in) AS tokens_in, | |
| 144 | SUM(tokens_out) AS tokens_out, SUM(cost_usd) AS cost_usd | |
| 145 | FROM usage WHERE issue_id = ?1`, | |
| 146 | ) | |
| 147 | .get(issueId); | |
| 148 | if (!row || row.runs === 0) return undefined; | |
| 149 | return { | |
| 150 | runs: row.runs, | |
| 151 | tokensIn: row.tokens_in, | |
| 152 | tokensOut: row.tokens_out, | |
| 153 | costUsd: row.cost_usd, | |
| 154 | }; | |
| 155 | } | |
| 156 | ||
| 129 | 157 | /** What the ledger can be summed by. Each maps to one fixed column. */ |
| 130 | 158 | export type UsageBy = "subscription" | "repo" | "agent" | "issue" | "kind"; |
| 131 | 159 | export const USAGE_BY: readonly UsageBy[] = [ |
src/kernel/tokens-format.ts added +175
| @@ -0,0 +1,175 @@ | ||
| 1 | /** | |
| 2 | * Tokens as a unit — the one formatter for a token count, and the one | |
| 3 | * estimate for text nobody has run through a model yet. | |
| 4 | * | |
| 5 | * A count is shown the way a byte size is (`web/ui.ts` `fileSize`): short, | |
| 6 | * tabular, never on its own (docs/DESIGN.md, "Token counts"). Two bases: | |
| 7 | * | |
| 8 | * - `counted` — the provider reported it (the usage ledger, `core/usage.ts`: | |
| 9 | * Claude's own tokenizer, whole tokens). Shown bare: `48k tok`. | |
| 10 | * - `estimated` — bytes divided by a calibrated bytes-per-token ratio for | |
| 11 | * the file's kind. Shown with a tilde: `~1.2k tok`. | |
| 12 | * | |
| 13 | * The ratios are an assumption, stated here and in every tooltip: Anthropic | |
| 14 | * documents roughly 3.5 English characters per Claude token, and OpenAI's | |
| 15 | * cl100k guidance is ~4 for prose. Code splits finer than prose (operators, | |
| 16 | * indentation, identifiers broken at case changes); structured data finer | |
| 17 | * still (quotes, braces, short keys). The ratios below are those rules of | |
| 18 | * thumb, not a measurement: good enough to feel a size, never to bill one. | |
| 19 | * | |
| 20 | * Pure: plain numbers and strings in and out, no I/O. | |
| 21 | */ | |
| 22 | ||
| 23 | export type TokenBasis = "counted" | "estimated"; | |
| 24 | ||
| 25 | /** What a file is, for the purpose of how finely it tokenizes. */ | |
| 26 | export type TextKind = "prose" | "code" | "data"; | |
| 27 | ||
| 28 | /** Bytes per token, by kind. Prose 4.0, code 3.5, data 3.0 (see above). */ | |
| 29 | export const BYTES_PER_TOKEN: Readonly<Record<TextKind, number>> = { | |
| 30 | prose: 4.0, | |
| 31 | code: 3.5, | |
| 32 | data: 3.0, | |
| 33 | }; | |
| 34 | ||
| 35 | const PROSE = new Set(["md", "markdown", "txt", "rst", "adoc", "org", "tex"]); | |
| 36 | const DATA = new Set([ | |
| 37 | "json", | |
| 38 | "jsonl", | |
| 39 | "ndjson", | |
| 40 | "yaml", | |
| 41 | "yml", | |
| 42 | "toml", | |
| 43 | "csv", | |
| 44 | "tsv", | |
| 45 | "xml", | |
| 46 | "svg", | |
| 47 | "html", | |
| 48 | "htm", | |
| 49 | "lock", | |
| 50 | "ini", | |
| 51 | "cfg", | |
| 52 | "sql", | |
| 53 | ]); | |
| 54 | // Files a model would not read as text at all: no estimate is better than | |
| 55 | // a confident wrong one. | |
| 56 | const BINARY = new Set([ | |
| 57 | "png", | |
| 58 | "jpg", | |
| 59 | "jpeg", | |
| 60 | "gif", | |
| 61 | "webp", | |
| 62 | "ico", | |
| 63 | "bmp", | |
| 64 | "avif", | |
| 65 | "pdf", | |
| 66 | "zip", | |
| 67 | "gz", | |
| 68 | "tgz", | |
| 69 | "bz2", | |
| 70 | "xz", | |
| 71 | "zst", | |
| 72 | "7z", | |
| 73 | "tar", | |
| 74 | "jar", | |
| 75 | "wasm", | |
| 76 | "woff", | |
| 77 | "woff2", | |
| 78 | "ttf", | |
| 79 | "otf", | |
| 80 | "eot", | |
| 81 | "mp3", | |
| 82 | "mp4", | |
| 83 | "mov", | |
| 84 | "webm", | |
| 85 | "ogg", | |
| 86 | "wav", | |
| 87 | "flac", | |
| 88 | "db", | |
| 89 | "sqlite", | |
| 90 | "so", | |
| 91 | "dylib", | |
| 92 | "dll", | |
| 93 | "exe", | |
| 94 | "bin", | |
| 95 | "o", | |
| 96 | "a", | |
| 97 | "class", | |
| 98 | "pyc", | |
| 99 | ]); | |
| 100 | ||
| 101 | function extension(path: string): string { | |
| 102 | const name = path.slice(path.lastIndexOf("/") + 1).toLowerCase(); | |
| 103 | const dot = name.lastIndexOf("."); | |
| 104 | return dot <= 0 ? "" : name.slice(dot + 1); | |
| 105 | } | |
| 106 | ||
| 107 | /** | |
| 108 | * How a path tokenizes, or `undefined` for a file that is not text. A name | |
| 109 | * with no extension (`Makefile`, `LICENSE`) counts as code: the finer ratio | |
| 110 | * errs toward more tokens, the safer side of an estimate about context. | |
| 111 | */ | |
| 112 | export function textKind(path: string): TextKind | undefined { | |
| 113 | const ext = extension(path); | |
| 114 | if (BINARY.has(ext)) return undefined; | |
| 115 | if (PROSE.has(ext)) return "prose"; | |
| 116 | if (DATA.has(ext)) return "data"; | |
| 117 | return "code"; | |
| 118 | } | |
| 119 | ||
| 120 | /** | |
| 121 | * The estimated token count of `bytes` of the file at `path`: bytes ÷ the | |
| 122 | * kind's ratio, rounded up (one byte is one token, never zero). `undefined` | |
| 123 | * for a binary file or a size that is not a byte count. | |
| 124 | */ | |
| 125 | export function estimateTokens(bytes: number, path: string): number | undefined { | |
| 126 | if (!Number.isSafeInteger(bytes) || bytes < 0) return undefined; | |
| 127 | const kind = textKind(path); | |
| 128 | if (kind === undefined) return undefined; | |
| 129 | return Math.ceil(bytes / BYTES_PER_TOKEN[kind]); | |
| 130 | } | |
| 131 | ||
| 132 | /** | |
| 133 | * A token count the way storage is written: `850 tok`, `1.2k tok`, | |
| 134 | * `48k tok`, `1.1M tok`. One decimal below ten of a unit, whole above; | |
| 135 | * rounding that reaches the next unit moves to it (999,999 is `1.0M`, not | |
| 136 | * `1000k`). An estimate carries a leading `~`. | |
| 137 | */ | |
| 138 | export function formatTokens(n: number, basis: TokenBasis = "counted"): string { | |
| 139 | const mark = basis === "estimated" ? "~" : ""; | |
| 140 | const count = Number.isFinite(n) && n > 0 ? Math.round(n) : 0; | |
| 141 | return `${mark}${scaled(count)} tok`; | |
| 142 | } | |
| 143 | ||
| 144 | function scaled(n: number): string { | |
| 145 | if (n < 1000) return String(n); | |
| 146 | for (const [unit, size] of [ | |
| 147 | ["k", 1e3], | |
| 148 | ["M", 1e6], | |
| 149 | ["B", 1e9], | |
| 150 | ] as const) { | |
| 151 | const v = n / size; | |
| 152 | const text = v < 9.95 ? v.toFixed(1) : String(Math.round(v)); | |
| 153 | if (Number(text) < 1000 || unit === "B") return `${text}${unit}`; | |
| 154 | } | |
| 155 | return String(n); | |
| 156 | } | |
| 157 | ||
| 158 | /** | |
| 159 | * The tooltip every count carries: which tokenizer, the exact number, and | |
| 160 | * — for an estimate — the ratio it came from and that it is one. | |
| 161 | */ | |
| 162 | export function tokenNote( | |
| 163 | n: number, | |
| 164 | basis: TokenBasis, | |
| 165 | kind?: TextKind, | |
| 166 | ): string { | |
| 167 | const exact = Math.max(0, Math.round(Number.isFinite(n) ? n : 0)); | |
| 168 | const count = `${exact.toLocaleString("en-US")} token${exact === 1 ? "" : "s"}`; | |
| 169 | if (basis === "counted") return `${count}, as counted by Claude's tokenizer`; | |
| 170 | const ratio = | |
| 171 | kind === undefined | |
| 172 | ? "3–4 bytes per token" | |
| 173 | : `${BYTES_PER_TOKEN[kind].toFixed(1)} bytes per token for ${kind}`; | |
| 174 | return `About ${count} — an estimate at ${ratio}; Claude's tokenizer will differ`; | |
| 175 | } |
src/web/pages/board.ts +5 −2
| @@ -50,7 +50,7 @@ import { repoBase, signInFirst } from "../chrome.ts"; | ||
| 50 | 50 | import { boardJs, liveRegionsJs } from "../client.ts"; |
| 51 | 51 | import { html, when, type Html } from "../html.ts"; |
| 52 | 52 | import { page, redirect } from "../layout.ts"; |
| 53 | import { avatar, icon, time } from "../ui.ts"; | |
| 53 | import { avatar, icon, time, tokenCount } from "../ui.ts"; | |
| 54 | 54 | import { voiceFor } from "../studio/voice.ts"; |
| 55 | 55 | import { |
| 56 | 56 | perShippedWords, |
| @@ -384,7 +384,10 @@ function windowsStrip(ctx: Ctx): Html | null { | ||
| 384 | 384 | ></span>` |
| 385 | 385 | } |
| 386 | 386 | <span class="board-window-nums" |
| 387 | >${figures([words.used, words.remaining, words.resets])}</span | |
| 387 | >${figures([words.used, words.remaining, words.resets])}${when( | |
| 388 | w.tokensIn + w.tokensOut > 0, | |
| 389 | () => html` · ${tokenCount(w.tokensIn + w.tokensOut, "counted")}`, | |
| 390 | )}</span | |
| 388 | 391 | ></span |
| 389 | 392 | >`; |
| 390 | 393 | })} |
src/web/pages/edit.ts +14 −1
| @@ -37,7 +37,7 @@ import { | ||
| 37 | 37 | type ProposeTarget, |
| 38 | 38 | type RepoScope, |
| 39 | 39 | } from "../scope.ts"; |
| 40 | import { btn, card, field, icon } from "../ui.ts"; | |
| 40 | import { btn, card, field, icon, sizeWithTokens } from "../ui.ts"; | |
| 41 | 41 | import { pathCrumbs, refSetFrom } from "./refs.ts"; |
| 42 | 42 | |
| 43 | 43 | /** What the form is holding, so a refusal can hand every word back. */ |
| @@ -190,6 +190,19 @@ function editorForm( | ||
| 190 | 190 | pathCrumbs(base, scope.facts.name, draft.ref, draft.path, true), |
| 191 | 191 | )} |
| 192 | 192 | <span class="spacer"></span> |
| 193 | ${when( | |
| 194 | !draft.creating, | |
| 195 | () => | |
| 196 | html`<span | |
| 197 | class="muted small num" | |
| 198 | id="editor-size" | |
| 199 | title="The file as opened, before your changes" | |
| 200 | >${sizeWithTokens( | |
| 201 | new TextEncoder().encode(draft.content).byteLength, | |
| 202 | draft.path, | |
| 203 | )}</span | |
| 204 | >`, | |
| 205 | )} | |
| 193 | 206 | ${when( |
| 194 | 207 | isMarkdownPath(draft.path) || draft.creating, |
| 195 | 208 | () => |
src/web/pages/issues.ts +2 −1
| @@ -61,7 +61,7 @@ import { | ||
| 61 | 61 | threadContext, |
| 62 | 62 | threadFragment, |
| 63 | 63 | } from "./thread.ts"; |
| 64 | import { crewActivityCard } from "./runs.ts"; | |
| 64 | import { crewActivityCard, workSpent } from "./runs.ts"; | |
| 65 | 65 | import { studioTells } from "../studio/lists.ts"; |
| 66 | 66 | import { |
| 67 | 67 | PAGE_TITLES, |
| @@ -540,6 +540,7 @@ export async function issuePage(ctx: Ctx): Promise<Response> { | ||
| 540 | 540 | >${icon("external")}view on origin</a |
| 541 | 541 | >`, |
| 542 | 542 | )} |
| 543 | ${workSpent(ctx, repo, issue.id)} | |
| 543 | 544 | </div> |
| 544 | 545 | </div> |
| 545 | 546 | <div class="split"> |
src/web/pages/pull.ts +2 −1
| @@ -96,7 +96,7 @@ import { | ||
| 96 | 96 | import { previewCard } from "./apps.ts"; |
| 97 | 97 | import { lookCard } from "./looks.ts"; |
| 98 | 98 | import { pullState } from "./pulls.ts"; |
| 99 | import { crewActivityCard } from "./runs.ts"; | |
| 99 | import { crewActivityCard, workSpent } from "./runs.ts"; | |
| 100 | 100 | import { studioPull } from "../studio/change.ts"; |
| 101 | 101 | import { studioRecord } from "../studio/nouns.ts"; |
| 102 | 102 | |
| @@ -834,6 +834,7 @@ export async function pullPage(ctx: Ctx): Promise<Response> { | ||
| 834 | 834 | >${icon("external")}view on origin</a |
| 835 | 835 | >`, |
| 836 | 836 | )} |
| 837 | ${workSpent(ctx, repo, pull.id)} | |
| 837 | 838 | <span class="mono">${pull.headBranch} → ${pull.baseBranch}</span> |
| 838 | 839 | ${badge(`${status.ahead} commit${status.ahead === 1 ? "" : "s"}`, { |
| 839 | 840 | variant: "secondary", |
src/web/pages/repo.ts +34 −3
| @@ -28,6 +28,7 @@ import { | ||
| 28 | 28 | import { isSafeRef, isSafeTreePath } from "../../git/guard.ts"; |
| 29 | 29 | import { blobForRender, type TreeEntry } from "../../git/read.ts"; |
| 30 | 30 | import { repoDir } from "../../git/repos.ts"; |
| 31 | import { estimateTokens } from "../../kernel/tokens-format.ts"; | |
| 31 | 32 | import { safeBack, type Ctx } from "../../http/context.ts"; |
| 32 | 33 | import { json, notFound } from "../../http/respond.ts"; |
| 33 | 34 | import type { Blob, RepoSource } from "../../view/source.ts"; |
| @@ -57,6 +58,8 @@ import { | ||
| 57 | 58 | card, |
| 58 | 59 | empty, |
| 59 | 60 | fileSize, |
| 61 | sizeWithTokens, | |
| 62 | tokenCount, | |
| 60 | 63 | icon, |
| 61 | 64 | iconBtn, |
| 62 | 65 | listCard, |
| @@ -328,14 +331,41 @@ export function treeListing( | ||
| 328 | 331 | entry.size >= 0, |
| 329 | 332 | () => |
| 330 | 333 | html`<span class="row-trail muted small num" |
| 331 | >${fileSize(entry.size)}</span | |
| 334 | >${sizeWithTokens(entry.size, target)}</span | |
| 332 | 335 | >`, |
| 333 | 336 | )} |
| 334 | 337 | </a> |
| 335 | 338 | </li>`, |
| 336 | 339 | ); |
| 337 | 340 | } |
| 338 | return listCard(rows); | |
| 341 | return html`${listCard(rows)}${directoryTotal(entries)}`; | |
| 342 | } | |
| 343 | ||
| 344 | /** | |
| 345 | * What the files at this level weigh together: bytes and estimated tokens, | |
| 346 | * the two summed over the same files. Subdirectories carry no size in a | |
| 347 | * tree listing (`git ls-tree -l` gives `-`), so they are not in it — the | |
| 348 | * line says "here" and the tooltip says why. Binary files add bytes but no | |
| 349 | * tokens. Nothing when there are no files. | |
| 350 | */ | |
| 351 | export function directoryTotal(entries: readonly TreeEntry[]): Html { | |
| 352 | let files = 0; | |
| 353 | let bytes = 0; | |
| 354 | let tokens = 0; | |
| 355 | for (const entry of entries) { | |
| 356 | if (entry.type !== "blob" || entry.size < 0) continue; | |
| 357 | files += 1; | |
| 358 | bytes += entry.size; | |
| 359 | tokens += estimateTokens(entry.size, entry.name) ?? 0; | |
| 360 | } | |
| 361 | if (files === 0) return html``; | |
| 362 | return html`<p | |
| 363 | class="muted small num tree-total" | |
| 364 | title="Files at this level only; subdirectories are not counted" | |
| 365 | > | |
| 366 | ${files.toLocaleString("en-US")} file${files === 1 ? "" : "s"} here · | |
| 367 | ${fileSize(bytes)} · ${tokenCount(tokens, "estimated")} | |
| 368 | </p>`; | |
| 339 | 369 | } |
| 340 | 370 | |
| 341 | 371 | /** |
| @@ -763,7 +793,8 @@ export function renderBlob( | ||
| 763 | 793 | view === "rendered" && o.blob.kind === "text" |
| 764 | 794 | ? renderedMarkdown(o.blob) |
| 765 | 795 | : blobBody(base, o.ref, o.path, o.blob); |
| 766 | const sizeText = o.blob.kind === "too_large" ? "" : fileSize(o.blob.size); | |
| 796 | const sizeText = | |
| 797 | o.blob.kind === "too_large" ? "" : sizeWithTokens(o.blob.size, o.path); | |
| 767 | 798 | const fileHref = `${base}/blob/${encoded}/${o.path}`; |
| 768 | 799 | return html`<div class="code-pane" id="code-pane" data-path="${o.path}"> |
| 769 | 800 | <div class="path-head"> |
src/web/pages/runs.ts +45 −1
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | * id, so the redirect itself says nothing about what exists. |
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | import type { Database } from "bun:sqlite"; | |
| 14 | 15 | import { |
| 15 | 16 | cancelRepoRun, |
| 16 | 17 | listRepoRuns, |
| @@ -18,9 +19,16 @@ import { | ||
| 18 | 19 | steerRepoRun, |
| 19 | 20 | } from "../../actions/runs.ts"; |
| 20 | 21 | import { getIssueById, getIssuesByIds, type Issue } from "../../core/issues.ts"; |
| 21 | import { canRead, canWrite, getRepoById, type Repo } from "../../core/repos.ts"; | |
| 22 | import { | |
| 23 | canRead, | |
| 24 | canSeeOwnerFacts, | |
| 25 | canWrite, | |
| 26 | getRepoById, | |
| 27 | type Repo, | |
| 28 | } from "../../core/repos.ts"; | |
| 22 | 29 | import { listHeldSecrets } from "../../core/run_secret.ts"; |
| 23 | 30 | import { getRun, isTerminal, type Run, type RunKind } from "../../core/runs.ts"; |
| 31 | import { getUsage, issueUsage } from "../../core/usage.ts"; | |
| 24 | 32 | import { getUserById, getUsersByIds, type User } from "../../core/users.ts"; |
| 25 | 33 | import type { Ctx } from "../../http/context.ts"; |
| 26 | 34 | import { |
| @@ -52,6 +60,7 @@ import { | ||
| 52 | 60 | seg, |
| 53 | 61 | sha, |
| 54 | 62 | time, |
| 63 | tokenCount, | |
| 55 | 64 | } from "../ui.ts"; |
| 56 | 65 | |
| 57 | 66 | // ---- words ---- |
| @@ -105,6 +114,38 @@ export const reasonWords = (reason: string): string => | ||
| 105 | 114 | /** Dollars, two places — a run's spend is a small number read at a glance. */ |
| 106 | 115 | export const costText = (usd: number): string => `$${usd.toFixed(2)}`; |
| 107 | 116 | |
| 117 | /** | |
| 118 | * What the crew's runs on one issue or pull spent altogether: the cost and, | |
| 119 | * beside it, the tokens read and written (`core/usage.ts` `issueUsage`). | |
| 120 | * The owner's facts, like a run's cost — nothing for a reader who cannot | |
| 121 | * write here, and nothing before a run has landed in the ledger. | |
| 122 | */ | |
| 123 | export function workSpent(ctx: Ctx, repo: Repo, issueId: string): Html { | |
| 124 | if (!canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user)) return html``; | |
| 125 | const used = issueUsage(ctx.app.db, issueId); | |
| 126 | if (used === undefined) return html``; | |
| 127 | return html`<span class="num" id="work-spent" | |
| 128 | >${used.runs} run${used.runs === 1 ? "" : "s"} · | |
| 129 | ${costText(used.costUsd)} · ${tokenCount(used.tokensIn, "counted")} in · | |
| 130 | ${tokenCount(used.tokensOut, "counted")} out</span | |
| 131 | >`; | |
| 132 | } | |
| 133 | ||
| 134 | /** | |
| 135 | * What a finished agent run read and wrote, in tokens, from the usage ledger | |
| 136 | * (`core/usage.ts`) — counted by the provider, so no tilde. Placed beside the | |
| 137 | * cost, never alone; nothing until the ledger has the run, or when it spent | |
| 138 | * nothing. | |
| 139 | */ | |
| 140 | export function runTokens(db: Database, run: Run): Html { | |
| 141 | const used = run.kind === "agent" ? getUsage(db, run.id) : undefined; | |
| 142 | if (used === undefined || used.tokensIn + used.tokensOut === 0) return html``; | |
| 143 | return html`<span class="num" id="run-tokens" | |
| 144 | >${tokenCount(used.tokensIn, "counted")} in · | |
| 145 | ${tokenCount(used.tokensOut, "counted")} out</span | |
| 146 | >`; | |
| 147 | } | |
| 148 | ||
| 108 | 149 | /** |
| 109 | 150 | * What kind of line a run wrote: a tool the agent reached for (`→ Bash …`), |
| 110 | 151 | * a word from the forge itself (cloning, pushing, what the model cost), or |
| @@ -408,6 +449,9 @@ export async function runPage(ctx: Ctx): Promise<Response> { | ||
| 408 | 449 | <span class="num" id="run-cost" ${run.costUsd > 0 ? null : html`hidden`} |
| 409 | 450 | >${costText(run.costUsd)} spent</span |
| 410 | 451 | > |
| 452 | ${when(canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user), () => | |
| 453 | runTokens(ctx.app.db, run), | |
| 454 | )} | |
| 411 | 455 | <span class="num" id="run-turns" ${run.turns > 0 ? null : html`hidden`} |
| 412 | 456 | >${run.turns} turn${run.turns === 1 ? "" : "s"}</span |
| 413 | 457 | > |
src/web/pages/subscriptions.ts +27 −6
| @@ -46,6 +46,7 @@ import { | ||
| 46 | 46 | row, |
| 47 | 47 | time, |
| 48 | 48 | timeAgo, |
| 49 | tokenCount, | |
| 49 | 50 | userLink, |
| 50 | 51 | } from "../ui.ts"; |
| 51 | 52 | import { whenText } from "../../crew/limits.ts"; |
| @@ -72,18 +73,34 @@ const kindText = (s: Subscription): string => | ||
| 72 | 73 | const usd = (n: number): string => `$${n.toFixed(2)}`; |
| 73 | 74 | const mark = (f: Figure<unknown>): string => (f.source === "read" ? "" : "~"); |
| 74 | 75 | |
| 75 | /** `5-hour: ~$3.20 used of ~$10.00 · resets 12:04 UTC` — one window, plainly. */ | |
| 76 | function windowText(w: WindowStanding): string { | |
| 76 | /** | |
| 77 | * `5-hour: ~$3.20 used of ~$10.00 (32%) · 1.2M tok · resets 12:04 UTC` — one | |
| 78 | * window, plainly: the dollars, the share of the cap when it is known, and | |
| 79 | * beside them the tokens the crew's runs read and wrote in it, so the money | |
| 80 | * and the context intuitions meet in one line. | |
| 81 | */ | |
| 82 | function windowText(w: WindowStanding): Html { | |
| 77 | 83 | const name = w.window === "5h" ? "5-hour" : "weekly"; |
| 78 | 84 | const used = `${mark(w.used)}${usd(w.used.value)} used`; |
| 79 | const of = w.cap === null ? "" : ` of ${mark(w.cap)}${usd(w.cap.value)}`; | |
| 85 | const of = | |
| 86 | w.cap === null | |
| 87 | ? "" | |
| 88 | : ` of ${mark(w.cap)}${usd(w.cap.value)}${ | |
| 89 | w.cap.value > 0 | |
| 90 | ? ` (${Math.min(100, Math.round((w.used.value / w.cap.value) * 100))}%)` | |
| 91 | : "" | |
| 92 | }`; | |
| 80 | 93 | const full = |
| 81 | 94 | w.remaining?.source === "read" && w.remaining.value === 0 ? " · full" : ""; |
| 82 | 95 | const resets = |
| 83 | 96 | w.resetsAt === null |
| 84 | 97 | ? " · opens on next use" |
| 85 | 98 | : ` · resets ${w.resetsAt.source === "read" ? "" : "~"}${whenText(new Date(w.resetsAt.value))}`; |
| 86 | return `${name}: ${used}${of}${full}${resets}`; | |
| 99 | const tokens = w.tokensIn + w.tokensOut; | |
| 100 | return html`${name}: ${used}${of}${when( | |
| 101 | tokens > 0, | |
| 102 | () => html` · ${tokenCount(tokens, "counted")}`, | |
| 103 | )}${full}${resets}`; | |
| 87 | 104 | } |
| 88 | 105 | |
| 89 | 106 | const resting = (s: Subscription, now: Date): boolean => |
| @@ -170,7 +187,9 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 170 | 187 | )}${when( |
| 171 | 188 | st !== undefined, |
| 172 | 189 | () => |
| 173 | html`<br />${st!.windows.map(windowText).join(" · ")}`, | |
| 190 | html`<br />${st!.windows.map((w, i) => | |
| 191 | i === 0 ? windowText(w) : html` · ${windowText(w)}`, | |
| 192 | )}`, | |
| 174 | 193 | )}`, |
| 175 | 194 | trail: html`${when( |
| 176 | 195 | st !== undefined, |
| @@ -298,7 +317,9 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 298 | 317 | Windows in US dollars of list price. A figure marked ~ is inferred |
| 299 | 318 | from what the crew's runs spent; the rest the provider said. The |
| 300 | 319 | cap is learned the first time the provider calls a window full, |
| 301 | and the share kept for people is never spent by the crew. | |
| 320 | and the share kept for people is never spent by the crew. Tokens | |
| 321 | are what the crew's runs read and wrote in the window, counted by | |
| 322 | Claude's tokenizer. | |
| 302 | 323 | </p>`, |
| 303 | 324 | )} |
| 304 | 325 | <div class="mt" id="spend" data-live="run.*,usage.*"> |
src/web/studio/change.ts +2
| @@ -31,6 +31,7 @@ import { | ||
| 31 | 31 | import { previewCard } from "../pages/apps.ts"; |
| 32 | 32 | import { lookCard } from "../pages/looks.ts"; |
| 33 | 33 | import { pullState } from "../pages/pulls.ts"; |
| 34 | import { workSpent } from "../pages/runs.ts"; | |
| 34 | 35 | import { composer, conversation, originGlyph } from "../pages/thread.ts"; |
| 35 | 36 | import { |
| 36 | 37 | decisionActions, |
| @@ -165,6 +166,7 @@ export async function studioPull( | ||
| 165 | 166 | >${icon("external")}view on origin</a |
| 166 | 167 | >`, |
| 167 | 168 | )} |
| 169 | ${workSpent(ctx, repo, pull.id)} | |
| 168 | 170 | </div> |
| 169 | 171 | </div>`; |
| 170 | 172 |
src/web/studio/run.ts +7 −1
| @@ -27,7 +27,12 @@ import { html, when, type Html } from "../html.ts"; | ||
| 27 | 27 | import { page } from "../layout.ts"; |
| 28 | 28 | import { btn, icon } from "../ui.ts"; |
| 29 | 29 | import { previewCard } from "../pages/apps.ts"; |
| 30 | import { costText, durationText, runTitle } from "../pages/runs.ts"; | |
| 30 | import { | |
| 31 | costText, | |
| 32 | durationText, | |
| 33 | runTitle, | |
| 34 | runTokens, | |
| 35 | } from "../pages/runs.ts"; | |
| 31 | 36 | import { sandboxLine } from "./sandbox.ts"; |
| 32 | 37 | import { logBox, runVoice, RUN_TRANSCRIPT_CAP } from "./log.ts"; |
| 33 | 38 | import { changeHref, tellHref } from "./nouns.ts"; |
| @@ -136,6 +141,7 @@ export async function studioRun( | ||
| 136 | 141 | ${run.costUsd > 0 ? null : html`hidden`} |
| 137 | 142 | >${costText(run.costUsd)} spent</span |
| 138 | 143 | > |
| 144 | ${runTokens(db, run)} | |
| 139 | 145 | <span |
| 140 | 146 | class="num" |
| 141 | 147 | id="run-turns" |
src/web/studio/tell.ts +7 −1
| @@ -35,7 +35,12 @@ import { | ||
| 35 | 35 | userLink, |
| 36 | 36 | } from "../ui.ts"; |
| 37 | 37 | import { previewCard } from "../pages/apps.ts"; |
| 38 | import { costText, durationText, reasonWords } from "../pages/runs.ts"; | |
| 38 | import { | |
| 39 | costText, | |
| 40 | durationText, | |
| 41 | reasonWords, | |
| 42 | workSpent, | |
| 43 | } from "../pages/runs.ts"; | |
| 39 | 44 | import { composer, conversation, originGlyph } from "../pages/thread.ts"; |
| 40 | 45 | import { changeHref, tellHref } from "./nouns.ts"; |
| 41 | 46 | import { |
| @@ -267,6 +272,7 @@ export async function studioTell( | ||
| 267 | 272 | >${icon("external")}view on origin</a |
| 268 | 273 | >`, |
| 269 | 274 | )} |
| 275 | ${workSpent(ctx, repo, issue.id)} | |
| 270 | 276 | ${issue.assigneeName !== null |
| 271 | 277 | ? html`<span |
| 272 | 278 | >${when(issue.assigneeIsAgent, () => icon("bot"))} |
src/web/style/content.ts +2
| @@ -213,6 +213,8 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 213 | 213 | .tree-row { padding: 8px 14px; min-height: 36px; } |
| 214 | 214 | .tree-row .ico { color: var(--faint); } |
| 215 | 215 | .tree-row[data-kind="tree"] .ico { color: var(--accent); } |
| 216 | .tree-total { margin: 6px 2px 0; text-align: right; } | |
| 217 | .tok { white-space: nowrap; } | |
| 216 | 218 | |
| 217 | 219 | /* The file's location stays on screen while its contents scroll past. Opaque |
| 218 | 220 | on purpose: code sliding under a blur is noise, not depth. */ |
src/web/ui.ts +30
| @@ -13,6 +13,13 @@ | ||
| 13 | 13 | import type { EnvState, EnvStatus } from "../apps/runtime.ts"; |
| 14 | 14 | import type { PreviewData } from "../apps/data.ts"; |
| 15 | 15 | import type { RunKind, RunState } from "../core/runs.ts"; |
| 16 | import { | |
| 17 | estimateTokens, | |
| 18 | formatTokens, | |
| 19 | textKind, | |
| 20 | tokenNote, | |
| 21 | type TokenBasis, | |
| 22 | } from "../kernel/tokens-format.ts"; | |
| 16 | 23 | import { icon, type IconName } from "./icons.ts"; |
| 17 | 24 | import { Html, html, raw, when } from "./html.ts"; |
| 18 | 25 | |
| @@ -114,6 +121,29 @@ export function fileSize(bytes: number): string { | ||
| 114 | 121 | return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; |
| 115 | 122 | } |
| 116 | 123 | |
| 124 | /** | |
| 125 | * A token count as a size: `~1.2k tok`, with the tokenizer and whether it is | |
| 126 | * an estimate in the tooltip (`kernel/tokens-format.ts`). Only ever placed | |
| 127 | * beside a byte size, a cost or a percentage — never as a badge of its own. | |
| 128 | */ | |
| 129 | export function tokenCount( | |
| 130 | n: number, | |
| 131 | basis: TokenBasis, | |
| 132 | kind?: "prose" | "code" | "data", | |
| 133 | ): Html { | |
| 134 | return html`<span class="tok" title="${tokenNote(n, basis, kind)}" | |
| 135 | >${formatTokens(n, basis)}</span | |
| 136 | >`; | |
| 137 | } | |
| 138 | ||
| 139 | /** A file's byte size and, when it is text, its estimated tokens beside it. */ | |
| 140 | export function sizeWithTokens(bytes: number, path: string): Html { | |
| 141 | const tokens = estimateTokens(bytes, path); | |
| 142 | return tokens === undefined | |
| 143 | ? html`${fileSize(bytes)}` | |
| 144 | : html`${fileSize(bytes)} · ${tokenCount(tokens, "estimated", textKind(path))}`; | |
| 145 | } | |
| 146 | ||
| 117 | 147 | // ---- buttons ---- |
| 118 | 148 | |
| 119 | 149 | export interface BtnOptions { |
test/tokens.test.ts added +262
| @@ -0,0 +1,262 @@ | ||
| 1 | /** | |
| 2 | * Tokens as a unit: one formatter, checked against a hand calculation, and | |
| 3 | * the quiet places a count appears — beside a file's bytes, a directory's | |
| 4 | * total, a run's cost, an issue's spend and a subscription's window — and | |
| 5 | * the places it must not, for a reader who may not see spend. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { openIssue } from "../src/actions/issues.ts"; | |
| 13 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import type { Repo } from "../src/core/repos.ts"; | |
| 16 | import { createRun } from "../src/core/runs.ts"; | |
| 17 | import { addSubscription } from "../src/core/subscriptions.ts"; | |
| 18 | import { issueUsage, recordUsage } from "../src/core/usage.ts"; | |
| 19 | import { createUser, type User } from "../src/core/users.ts"; | |
| 20 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 21 | import { | |
| 22 | estimateTokens, | |
| 23 | formatTokens, | |
| 24 | textKind, | |
| 25 | tokenNote, | |
| 26 | } from "../src/kernel/tokens-format.ts"; | |
| 27 | ||
| 28 | describe("the formatter", () => { | |
| 29 | test("writes counts the way storage is written", () => { | |
| 30 | const cases: [number, string][] = [ | |
| 31 | [0, "0 tok"], | |
| 32 | [850, "850 tok"], | |
| 33 | [999, "999 tok"], | |
| 34 | [1000, "1.0k tok"], | |
| 35 | [1234, "1.2k tok"], | |
| 36 | [9949, "9.9k tok"], | |
| 37 | [9950, "10k tok"], // 9.95k rounds to the whole unit, not "10.0k" | |
| 38 | [48_000, "48k tok"], | |
| 39 | [999_499, "999k tok"], | |
| 40 | [999_500, "1.0M tok"], // never "1000k" | |
| 41 | [1_100_000, "1.1M tok"], | |
| 42 | [12_345_678, "12M tok"], | |
| 43 | [2_500_000_000, "2.5B tok"], | |
| 44 | ]; | |
| 45 | for (const [n, text] of cases) expect(formatTokens(n)).toBe(text); | |
| 46 | }); | |
| 47 | ||
| 48 | test("an estimate carries a tilde; nonsense is zero, never NaN", () => { | |
| 49 | expect(formatTokens(1234, "estimated")).toBe("~1.2k tok"); | |
| 50 | expect(formatTokens(Number.NaN)).toBe("0 tok"); | |
| 51 | expect(formatTokens(-5)).toBe("0 tok"); | |
| 52 | }); | |
| 53 | ||
| 54 | test("the tooltip names the tokenizer and says when it is an estimate", () => { | |
| 55 | expect(tokenNote(46_500, "counted")).toBe( | |
| 56 | "46,500 tokens, as counted by Claude's tokenizer", | |
| 57 | ); | |
| 58 | const est = tokenNote(200, "estimated", "code"); | |
| 59 | expect(est).toContain("About 200 tokens"); | |
| 60 | expect(est).toContain("an estimate at 3.5 bytes per token for code"); | |
| 61 | expect(tokenNote(1, "counted")).toStartWith("1 token,"); | |
| 62 | }); | |
| 63 | }); | |
| 64 | ||
| 65 | describe("the estimate", () => { | |
| 66 | test("is bytes ÷ the kind's ratio, rounded up", () => { | |
| 67 | // Prose 4.0, code 3.5, data 3.0 bytes per token. | |
| 68 | expect(estimateTokens(4000, "README.md")).toBe(1000); | |
| 69 | expect(estimateTokens(700, "src/a.ts")).toBe(200); | |
| 70 | expect(estimateTokens(701, "src/a.ts")).toBe(201); // 200.29 → 201 | |
| 71 | expect(estimateTokens(3000, "data.json")).toBe(1000); | |
| 72 | expect(estimateTokens(1, "Makefile")).toBe(1); // no extension: code | |
| 73 | expect(estimateTokens(0, "empty.txt")).toBe(0); | |
| 74 | }); | |
| 75 | ||
| 76 | test("a binary file or a size that is not a byte count has none", () => { | |
| 77 | expect(estimateTokens(5000, "logo.PNG")).toBeUndefined(); | |
| 78 | expect(estimateTokens(5000, "dist/app.wasm")).toBeUndefined(); | |
| 79 | expect(estimateTokens(-1, "src")).toBeUndefined(); | |
| 80 | expect(estimateTokens(1.5, "a.ts")).toBeUndefined(); | |
| 81 | expect(textKind(".bashrc")).toBe("code"); // a leading dot is a name | |
| 82 | expect(textKind("notes.MD")).toBe("prose"); | |
| 83 | }); | |
| 84 | }); | |
| 85 | ||
| 86 | describe("token counts on the pages", () => { | |
| 87 | let dataDir: string; | |
| 88 | let app: App; | |
| 89 | let servers: Servers; | |
| 90 | let alice: User; | |
| 91 | let repo: Repo; | |
| 92 | let cookie = ""; | |
| 93 | let bobCookie = ""; | |
| 94 | let runId = ""; | |
| 95 | let issueNumber = 0; | |
| 96 | ||
| 97 | const signIn = async (username: string) => | |
| 98 | ( | |
| 99 | ( | |
| 100 | await fetch(`${servers.url}/signin`, { | |
| 101 | method: "POST", | |
| 102 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 103 | redirect: "manual", | |
| 104 | }) | |
| 105 | ).headers.get("set-cookie") ?? "" | |
| 106 | ).split(";")[0]!; | |
| 107 | const get = async (path: string, c: string) => | |
| 108 | (await fetch(`${servers.url}${path}`, { headers: { cookie: c } })).text(); | |
| 109 | ||
| 110 | beforeAll(async () => { | |
| 111 | dataDir = await mkdtemp(join(tmpdir(), "forge-tokens-")); | |
| 112 | app = createApp({ dataDir, port: 0 }); | |
| 113 | servers = await serve(app); | |
| 114 | for (const [username, adminIfFirst] of [ | |
| 115 | ["alice", true], | |
| 116 | ["bob", false], | |
| 117 | ] as const) { | |
| 118 | const u = await createUser(app.db, { | |
| 119 | username, | |
| 120 | email: `${username}@forge.local`, | |
| 121 | password: "a-good-passphrase", | |
| 122 | adminIfFirst, | |
| 123 | }); | |
| 124 | if (!u.ok) throw new Error(u.error); | |
| 125 | if (username === "alice") alice = u.value; | |
| 126 | } | |
| 127 | const made = await createRepo(app, alice, { name: "one", visibility: "public" }); | |
| 128 | if (!made.ok) throw new Error("repo"); | |
| 129 | repo = made.value; | |
| 130 | const committed = await commitToBranch(app, alice, repo, { | |
| 131 | files: [ | |
| 132 | { path: "README.md", content: "a".repeat(4000) }, | |
| 133 | { path: "data.json", content: "1".repeat(3000) }, | |
| 134 | { path: "logo.png", content: new Uint8Array(8) }, | |
| 135 | { path: "src/a.ts", content: "x".repeat(700) }, | |
| 136 | ], | |
| 137 | }); | |
| 138 | if (!committed.ok) throw new Error(`commit: ${String(committed.error)}`); | |
| 139 | ||
| 140 | const issue = openIssue(app, alice, repo, { title: "make it so" }); | |
| 141 | if (!issue.ok) throw new Error("issue"); | |
| 142 | issueNumber = issue.value.number; | |
| 143 | const line = { | |
| 144 | repoId: repo.id, | |
| 145 | issueId: issue.value.id, | |
| 146 | agentId: null, | |
| 147 | subscriptionId: null, | |
| 148 | wallMs: 60_000, | |
| 149 | outcome: "ok" as const, | |
| 150 | at: new Date().toISOString(), | |
| 151 | }; | |
| 152 | runId = createRun(app.db, { | |
| 153 | repoId: repo.id, | |
| 154 | kind: "agent", | |
| 155 | issueId: issue.value.id, | |
| 156 | }).id; | |
| 157 | recordUsage(app.db, { | |
| 158 | ...line, | |
| 159 | runId, | |
| 160 | tokensIn: 46_500, | |
| 161 | tokensOut: 800, | |
| 162 | costUsd: 0.42, | |
| 163 | }); | |
| 164 | const second = createRun(app.db, { | |
| 165 | repoId: repo.id, | |
| 166 | kind: "agent", | |
| 167 | issueId: issue.value.id, | |
| 168 | }).id; | |
| 169 | recordUsage(app.db, { | |
| 170 | ...line, | |
| 171 | runId: second, | |
| 172 | tokensIn: 3500, | |
| 173 | tokensOut: 200, | |
| 174 | costUsd: 0.08, | |
| 175 | }); | |
| 176 | ||
| 177 | cookie = await signIn("alice"); | |
| 178 | bobCookie = await signIn("bob"); | |
| 179 | }); | |
| 180 | afterAll(async () => { | |
| 181 | servers.stop(); | |
| 182 | app.close(); | |
| 183 | await rm(dataDir, { recursive: true, force: true }); | |
| 184 | }); | |
| 185 | ||
| 186 | test("a directory lists each file's tokens beside its bytes, and a total", async () => { | |
| 187 | const page = await get("/alice/one/tree/main", cookie); | |
| 188 | // README.md: 4000 B = 3.9 KB; 4000 ÷ 4.0 = 1000 tokens. | |
| 189 | expect(page).toContain("3.9 KB · <span"); | |
| 190 | expect(page).toContain(">~1.0k tok</span"); | |
| 191 | // The binary file has bytes and no tokens. | |
| 192 | expect(page).toContain(">8 B</span"); | |
| 193 | // Files here: 4000 + 3000 + 8 = 7008 B = 6.8 KB; 1000 + 1000 + 0 tokens. | |
| 194 | expect(page).toMatch(/3 files here ·\s+6\.8 KB ·\s+<span[^>]*>~2\.0k tok</); | |
| 195 | expect(page).toContain("subdirectories are not counted"); | |
| 196 | }); | |
| 197 | ||
| 198 | test("a file shows its tokens beside its size, with the estimate said", async () => { | |
| 199 | const page = await get("/alice/one/blob/main/src/a.ts", cookie); | |
| 200 | // 700 B; 700 ÷ 3.5 = 200 tokens. | |
| 201 | expect(page).toMatch( | |
| 202 | /1 lines · 700 B · <span class="tok" title="About 200 tokens — an estimate at 3\.5 bytes per token for code/, | |
| 203 | ); | |
| 204 | expect(page).toContain(">~200 tok</span"); | |
| 205 | }); | |
| 206 | ||
| 207 | test("the editor shows the file's size and tokens as opened", async () => { | |
| 208 | const page = await get("/alice/one/edit/main/data.json", cookie); | |
| 209 | expect(page).toContain('id="editor-size"'); | |
| 210 | // 3000 B = 2.9 KB; 3000 ÷ 3.0 = 1000 tokens. | |
| 211 | expect(page).toMatch(/2\.9 KB · <span class="tok"[^>]*>~1\.0k tok</); | |
| 212 | }); | |
| 213 | ||
| 214 | test("a run shows tokens in and out beside its cost, to the owner only", async () => { | |
| 215 | const page = await get(`/alice/one/runs/${runId}`, cookie); | |
| 216 | // 46,500 in → 47k; 800 out. Counted, so no tilde. | |
| 217 | expect(page).toMatch( | |
| 218 | /id="run-tokens"\s*><span class="tok" title="46,500 tokens, as counted by Claude's tokenizer"\s*>47k tok<\/span\s*>\s*in ·/, | |
| 219 | ); | |
| 220 | expect(page).toContain(">800 tok</span"); | |
| 221 | const read = await get(`/alice/one/runs/${runId}`, bobCookie); | |
| 222 | expect(read).not.toContain('id="run-tokens"'); | |
| 223 | }); | |
| 224 | ||
| 225 | test("an issue shows what the work on it consumed, summed by hand", async () => { | |
| 226 | // 46,500 + 3,500 = 50,000 in; 800 + 200 = 1,000 out; $0.42 + $0.08. | |
| 227 | expect(issueUsage(app.db, repo.id)).toBeUndefined(); | |
| 228 | const page = await get(`/alice/one/issues/${issueNumber}`, cookie); | |
| 229 | expect(page).toMatch(/id="work-spent"\s*>2 runs ·\s+\$0\.50 ·/); | |
| 230 | expect(page).toContain(">50k tok</span"); | |
| 231 | expect(page).toContain(">1.0k tok</span"); | |
| 232 | const read = await get(`/alice/one/issues/${issueNumber}`, bobCookie); | |
| 233 | expect(read).not.toContain('id="work-spent"'); | |
| 234 | }); | |
| 235 | ||
| 236 | test("a subscription's windows carry tokens beside the dollars", async () => { | |
| 237 | const added = addSubscription(app.db, dataDir, { | |
| 238 | name: "account one", | |
| 239 | token: "sk-ant-oat01-tokens-test-account", | |
| 240 | }); | |
| 241 | if (!added.ok) throw new Error(added.error); | |
| 242 | recordUsage(app.db, { | |
| 243 | runId: createRun(app.db, { repoId: repo.id, kind: "agent" }).id, | |
| 244 | repoId: repo.id, | |
| 245 | issueId: null, | |
| 246 | agentId: null, | |
| 247 | subscriptionId: added.value.id, | |
| 248 | tokensIn: 1_200_000, | |
| 249 | tokensOut: 34_000, | |
| 250 | costUsd: 2, | |
| 251 | wallMs: 1000, | |
| 252 | outcome: "ok", | |
| 253 | at: new Date(Date.now() - 3_600_000).toISOString(), | |
| 254 | }); | |
| 255 | // 1,200,000 + 34,000 = 1,234,000 → 1.2M, in both windows. | |
| 256 | const settings = await get("/settings/subscriptions", cookie); | |
| 257 | expect(settings.match(/>1\.2M tok</g)?.length).toBe(2); | |
| 258 | expect(settings).toContain("counted by\n Claude's tokenizer"); | |
| 259 | const board = await get("/board", cookie); | |
| 260 | expect(board.match(/>1\.2M tok</g)?.length).toBe(2); | |
| 261 | }); | |
| 262 | }); |
new above
offline