pi-tokenburn 0.5.8 โ 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -4
- package/extensions/command/completions.ts +80 -0
- package/extensions/command/handlers.ts +22 -0
- package/extensions/command/host.ts +25 -0
- package/extensions/command/parse.ts +173 -0
- package/extensions/core/aggregate.ts +154 -0
- package/extensions/core/config.ts +161 -0
- package/extensions/core/format.ts +12 -0
- package/extensions/core/paint.ts +35 -0
- package/extensions/core/parser.ts +261 -0
- package/extensions/core/select.ts +114 -0
- package/extensions/core/time.ts +25 -0
- package/extensions/core/types.ts +43 -0
- package/extensions/features/breakdown/commands.ts +32 -0
- package/extensions/features/breakdown/query.ts +137 -0
- package/extensions/features/breakdown/table.ts +100 -0
- package/extensions/features/budgets/budgets.ts +105 -0
- package/extensions/features/budgets/commands.ts +20 -0
- package/extensions/features/export/commands.ts +19 -0
- package/extensions/features/export/export.ts +61 -0
- package/extensions/features/filter/commands.ts +18 -0
- package/extensions/features/live/coalescer.ts +71 -0
- package/extensions/features/pricing/commands.ts +30 -0
- package/extensions/features/pricing/report.ts +59 -0
- package/extensions/features/report/chart.ts +103 -0
- package/extensions/features/report/commands.ts +33 -0
- package/extensions/features/report/report.ts +47 -0
- package/extensions/features/status-line/commands.ts +78 -0
- package/extensions/features/status-line/render.ts +43 -0
- package/extensions/features/status-line/status.ts +34 -0
- package/extensions/tokenburn.ts +78 -1030
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -15,7 +15,10 @@ Totals match the `tokenburn` CLI exactly.
|
|
|
15
15
|
|
|
16
16
|
## Features
|
|
17
17
|
|
|
18
|
-
- **Status bar:** `๐ฅ 1.
|
|
18
|
+
- **Status bar:** `๐ฅ 1.2M ($5.10) [today] ยท 52k/h ยท 95% hit ยท ~$12.40 saved`: tokens, cost, burn rate, cache hit ratio and estimated cache savings for the chosen period (each extra toggleable). Turns red with `OVER BUDGET` past your limit.
|
|
19
|
+
- **Tables:** usage per model, provider, project and session, for any period, with cache hit %, savings, cost and share.
|
|
20
|
+
- **Filter and export:** limit everything to a provider, model or project; write csv or json.
|
|
21
|
+
- **Pricing:** a price table fills in (or overrides) costs the logs don't have.
|
|
19
22
|
- **Report chart:** `/tokenburn` shows the summary plus a vertical column chart (y-axis, budget line, current period marked, over-budget columns in red, peak/avg footer). Daily (14 days), weekly (8 weeks) or monthly (6 months).
|
|
20
23
|
- **Status line:** `/tokenburn day|week|month|year|all` (or `cycle`) changes the period shown at the bottom. Charts live under `/tokenburn chart <view>`.
|
|
21
24
|
- **Window panel (optional):** Today / Week / Month / Total below the editor, with in/out tokens and cost.
|
|
@@ -46,6 +49,20 @@ pi install git:github.com/sorinirimies/pi-tokenburn
|
|
|
46
49
|
| `/tokenburn` | Report + daily chart (same as `/tokenburn chart day`) |
|
|
47
50
|
| `/tokenburn cycle` | Rotate the status line: today โ week โ month โ year โ total |
|
|
48
51
|
| `/tokenburn status <today\|week\|month\|year\|total>` | Explicit form of the first row |
|
|
52
|
+
| `/tokenburn models hour|day|week|month|year|all` | **Table** of usage per model (tokens, in/out, cache hit %, cache savings, cost, share). Period (optional): `hour` (last 60 min), `day`, `week`, `month`, `year`, `all` (default `all`) |
|
|
53
|
+
| `/tokenburn providers hour|day|week|month|year|all` | Same table per provider |
|
|
54
|
+
| `/tokenburn projects hour|day|week|month|year|all` | Same table per project (session working directory); all projects by default |
|
|
55
|
+
| `/tokenburn sessions hour|day|week|month|year|all` | Leaderboard of the `n` (default 10) costliest sessions |
|
|
56
|
+
| `/tokenburn session` | Spend of the **current** session, and its share of today |
|
|
57
|
+
| `/tokenburn filter <provider\|model\|project> <value>` | Limit everything (status line, report, tables, export) to matching usage (case-insensitive substring). `filter` shows it, `filter all` clears it |
|
|
58
|
+
| `/tokenburn export csv|json` | Optional `[period]` and `[path]` follow the format. Writes one row per day ร provider ร model ร project (default `tokenburn-<period>.<format>` in the current directory) |
|
|
59
|
+
| `/tokenburn warn <pct>` | Notify once when a budget reaches `pct` percent (default 80) and once when it is exceeded |
|
|
60
|
+
| `/tokenburn rate` | Toggle the burn rate in the status line (`42k/h`: tokens per hour averaged over the status period) |
|
|
61
|
+
| `/tokenburn hit` | Toggle the cache hit ratio (`95% hit`) in the status line |
|
|
62
|
+
| `/tokenburn saved` | Toggle the estimated cache savings (`~$12.40 saved`) in the status line |
|
|
63
|
+
| `/tokenburn hour` | Show the last 60 minutes in the status line (joins `day`, `week`, `month`, `year`, `all`) |
|
|
64
|
+
| `/tokenburn pricing` | Where costs come from (session logs vs your price table) and which models are unpriced |
|
|
65
|
+
| `/tokenburn price <model> <in> <out>` | Set a price in $ per million tokens (key `model` or `provider/model`); `price remove <model>` deletes it. `pricing mode <fill\|override>`: use the table only where the log has no cost (default), or always |
|
|
49
66
|
| `/tokenburn window` | Toggle the panel below the editor |
|
|
50
67
|
| `/tokenburn budget <day\|week\|month> <tokens>` | Token budget, e.g. `budget day 5000000` |
|
|
51
68
|
| `/tokenburn budget <day\|week\|month> $<cost>` | Dollar budget, e.g. `budget week $50` |
|
|
@@ -58,9 +75,9 @@ pi install git:github.com/sorinirimies/pi-tokenburn
|
|
|
58
75
|
|
|
59
76
|
Recorded from a real pi with only this plugin loaded, on synthetic data.
|
|
60
77
|
|
|
61
|
-
**Switch what the bottom line shows** (`/tokenburn day|week|month|year|all`, or `cycle`):
|
|
78
|
+
**Switch what the bottom line shows** (`/tokenburn hour|day|week|month|year|all`, or `cycle`):
|
|
62
79
|
|
|
63
|
-

|
|
80
|
+

|
|
64
81
|
|
|
65
82
|
**Charts** (`/tokenburn chart day|week|month|year|all`): the current period is green, with a peak and average footer:
|
|
66
83
|
|
|
@@ -78,6 +95,22 @@ Recorded from a real pi with only this plugin loaded, on synthetic data.
|
|
|
78
95
|
|
|
79
96
|

|
|
80
97
|
|
|
98
|
+
**Tables** (`/tokenburn models week`, `providers month`, `projects`, `sessions week 5`): who spends what, with cache hit %, estimated savings and share:
|
|
99
|
+
|
|
100
|
+

|
|
101
|
+
|
|
102
|
+
**Filter and export** (`/tokenburn filter model acme-large`, `export csv week usage.csv`): the footer, tables and the file all follow the filter:
|
|
103
|
+
|
|
104
|
+

|
|
105
|
+
|
|
106
|
+
**Footer extras** (`/tokenburn hour`, `rate`, `hit`, `saved`): the burn rate, cache hit ratio and cache savings follow the chosen period, and each can be switched off:
|
|
107
|
+
|
|
108
|
+

|
|
109
|
+
|
|
110
|
+
**Pricing** (`/tokenburn pricing`, `price acme-large 3 15 0.3 3.75`): where costs come from, and a price table that can fill in or override them:
|
|
111
|
+
|
|
112
|
+

|
|
113
|
+
|
|
81
114
|
**Tab-completion** uses pi's own menu, with a description for every option:
|
|
82
115
|
|
|
83
116
|

|
|
@@ -142,7 +175,7 @@ Refreshing is designed to cost nothing you can notice:
|
|
|
142
175
|
|
|
143
176
|
## Demo recordings
|
|
144
177
|
|
|
145
|
-
The GIFs above live in [`examples/vhs/generated/`](examples/vhs/generated) and are stored with **Git LFS** (`git lfs install` once). They are recorded with [VHS](https://github.com/charmbracelet/vhs) from a **real pi** that loads only this extension, on **synthetic** data (`examples/vhs/fixture.sh`: 900 days of made-up sessions in `/tmp
|
|
178
|
+
The GIFs above live in [`examples/vhs/generated/`](examples/vhs/generated) and are stored with **Git LFS** (`git lfs install` once). They are recorded with [VHS](https://github.com/charmbracelet/vhs) from a **real pi** that loads only this extension, on **synthetic** data (`examples/vhs/fixture.sh`: 900 days of made-up sessions across fictional projects, providers and models in `/tmp`, with deliberately small numbers, and a local mock model for the live-refresh demo), never from real logs, paths or credentials:
|
|
146
179
|
|
|
147
180
|
```sh
|
|
148
181
|
just vhs-all # every tape (examples/vhs/*.tape): needs vhs, ttyd, ffmpeg, pi, bun, python3
|
|
@@ -169,6 +202,28 @@ just push-all # push main to every configured remote (origin + the Gitea i
|
|
|
169
202
|
|
|
170
203
|
**Coverage:** every `bun test` collects coverage (`bunfig.toml`) and **fails below 95% lines / 95% functions**. CI shows the table on the run page and uploads `lcov.info`.
|
|
171
204
|
|
|
205
|
+
### Code layout
|
|
206
|
+
|
|
207
|
+
One folder per feature under `extensions/features/`, each with its logic and its `/tokenburn` commands:
|
|
208
|
+
|
|
209
|
+
```
|
|
210
|
+
extensions/
|
|
211
|
+
tokenburn.ts wiring only: pi events + the /tokenburn command
|
|
212
|
+
core/ types, config, session-log parser (incremental cache), aggregation, filters + prices, paint
|
|
213
|
+
command/ parse + completions (pure), handler table, the Host handlers receive
|
|
214
|
+
features/
|
|
215
|
+
status-line/ footer text, optional panel, burn rate / cache hit / savings
|
|
216
|
+
report/ the /tokenburn summary and the column charts
|
|
217
|
+
budgets/ budgets, projection ("runs out Thu 14:05"), one-time warnings
|
|
218
|
+
breakdown/ tables per model / provider / project / session, current session
|
|
219
|
+
filter/ limit everything to a provider / model / project
|
|
220
|
+
pricing/ cost sources, price table
|
|
221
|
+
export/ csv / json
|
|
222
|
+
live/ refresh scheduling
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
To add a command: add its kind to `command/parse.ts`, a handler in the owning feature's `commands.ts` (the `Handlers` type makes a missing one a compile error), and a test.
|
|
226
|
+
|
|
172
227
|
CI scripts are [nushell](https://www.nushell.sh) (`scripts/`), the same ones locally and in GitHub / Gitea Actions.
|
|
173
228
|
`just --list` shows every task.
|
|
174
229
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Tab-completion for /tokenburn (pure, unit-tested). */
|
|
2
|
+
import { EXPORT_FORMATS } from "../features/export/export.ts";
|
|
3
|
+
import { FILTER_KEYS_LIST } from "../core/select.ts";
|
|
4
|
+
import { PERIOD_ALIASES } from "../features/breakdown/query.ts";
|
|
5
|
+
import { STATUS_WINDOWS } from "../core/types.ts";
|
|
6
|
+
import { BUDGET_PERIODS, REPORT_VIEWS, type ReportView } from "./parse.ts";
|
|
7
|
+
|
|
8
|
+
export interface CompletionItem {
|
|
9
|
+
value: string;
|
|
10
|
+
/** Required by pi: it calls `label.endsWith(...)` when applying a completion. */
|
|
11
|
+
label: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const CHART_SPAN: Record<ReportView, string> = {
|
|
16
|
+
day: "last 14 days",
|
|
17
|
+
week: "last 8 weeks",
|
|
18
|
+
month: "last 6 months",
|
|
19
|
+
year: "last 5 years",
|
|
20
|
+
all: "whole history",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const TOP_LEVEL: CompletionItem[] = [
|
|
24
|
+
{ value: "hour", label: "hour", description: "Status bar: last 60 minutes" },
|
|
25
|
+
{ value: "day", label: "day", description: "Status bar: today" },
|
|
26
|
+
{ value: "week", label: "week", description: "Status bar: this week" },
|
|
27
|
+
{ value: "month", label: "month", description: "Status bar: this month" },
|
|
28
|
+
{ value: "year", label: "year", description: "Status bar: this year" },
|
|
29
|
+
{ value: "all", label: "all", description: "Status bar: all-time total" },
|
|
30
|
+
{ value: "chart ", label: "chart", description: "Report + chart: day, week, month, year or all" },
|
|
31
|
+
{ value: "status ", label: "status", description: "Set the status-bar period explicitly" },
|
|
32
|
+
{ value: "models ", label: "models", description: "Table: usage per model (default all time)" },
|
|
33
|
+
{ value: "providers ", label: "providers", description: "Table: usage per provider" },
|
|
34
|
+
{ value: "projects ", label: "projects", description: "Table: usage per project (default all projects)" },
|
|
35
|
+
{ value: "sessions ", label: "sessions", description: "Leaderboard: costliest sessions" },
|
|
36
|
+
{ value: "session", label: "session", description: "Spend of the current session" },
|
|
37
|
+
{ value: "filter ", label: "filter", description: "Limit everything to a provider / model / project" },
|
|
38
|
+
{ value: "export ", label: "export", description: "Write usage to a csv or json file" },
|
|
39
|
+
{ value: "warn ", label: "warn", description: "Budget warning threshold in percent (or off)" },
|
|
40
|
+
{ value: "pricing", label: "pricing", description: "Where costs come from; price table status" },
|
|
41
|
+
{ value: "price ", label: "price", description: "Set a $/M-token price for a model" },
|
|
42
|
+
{ value: "rate", label: "rate", description: "Toggle the tokens/hour burn rate in the status line" },
|
|
43
|
+
{ value: "hit", label: "hit", description: "Toggle the cache hit ratio in the status line" },
|
|
44
|
+
{ value: "saved", label: "saved", description: "Toggle the estimated cache savings in the status line" },
|
|
45
|
+
{ value: "cycle", label: "cycle", description: "Rotate the status-bar period" },
|
|
46
|
+
{ value: "window", label: "window", description: "Toggle the panel below the editor" },
|
|
47
|
+
{ value: "budget ", label: "budget", description: "Set a day/week/month budget" },
|
|
48
|
+
{ value: "cache", label: "cache", description: "Toggle counting cache tokens" },
|
|
49
|
+
{ value: "live", label: "live", description: "Toggle refreshing after each message" },
|
|
50
|
+
{ value: "enable", label: "enable", description: "Turn tokenburn on (status line, panel, refresh)" },
|
|
51
|
+
{ value: "disable", label: "disable", description: "Turn tokenburn off: nothing runs in the background" },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/** `prefix` is the whole argument text typed so far (pi replaces it with `value`). */
|
|
55
|
+
export function completions(prefix: string): CompletionItem[] | null {
|
|
56
|
+
const text = (prefix ?? "").toLowerCase().replace(/^\s+/, "");
|
|
57
|
+
// `tail` = " " when the completed text still needs another word (a budget amount).
|
|
58
|
+
const sub = (head: string, words: readonly string[], desc: (w: string) => string, tail = ""): CompletionItem[] =>
|
|
59
|
+
words.map((w) => ({ value: `${head} ${w}${tail}`, label: w, description: desc(w) }));
|
|
60
|
+
|
|
61
|
+
let pool: CompletionItem[];
|
|
62
|
+
if (text.startsWith("status ")) pool = sub("status", STATUS_WINDOWS, (w) => `Show ${w} in the status bar`);
|
|
63
|
+
else if (text.startsWith("budget ") && !/^budget \S+ /.test(text))
|
|
64
|
+
pool = sub("budget", BUDGET_PERIODS, (w) => `Set the ${w} budget (tokens or $cost)`, " ");
|
|
65
|
+
else if (/^(models?|providers?|projects?|sessions|top) /.test(text)) {
|
|
66
|
+
const head = text.split(" ")[0];
|
|
67
|
+
pool = sub(head, Object.keys(PERIOD_ALIASES).filter((w) => w !== "today" && w !== "total"), (w) => `Usage ${w === "all" ? "over all time" : `for ${w}`}`);
|
|
68
|
+
} else if (text.startsWith("filter ") && !/^filter \S+ /.test(text)) pool = [
|
|
69
|
+
...sub("filter", FILTER_KEYS_LIST, (w) => `Only this ${w}`, " "), // still needs a value
|
|
70
|
+
...sub("filter", ["all"], () => "Clear the filter"), // complete as it is
|
|
71
|
+
];
|
|
72
|
+
else if (text.startsWith("export ") && !/^export \S+ /.test(text)) pool = sub("export", EXPORT_FORMATS, (w) => `Export as ${w}`);
|
|
73
|
+
else if (/^(report|chart) /.test(text)) {
|
|
74
|
+
const head = text.split(" ")[0];
|
|
75
|
+
pool = sub(head, REPORT_VIEWS, (w) => `Chart: ${CHART_SPAN[w as ReportView]}`);
|
|
76
|
+
} else pool = TOP_LEVEL;
|
|
77
|
+
|
|
78
|
+
const out = pool.filter((i) => i.value.startsWith(text));
|
|
79
|
+
return out.length > 0 ? out : null;
|
|
80
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Every command kind mapped to the feature that handles it (compile-time exhaustive). */
|
|
2
|
+
import { budgetCommands } from "../features/budgets/commands.ts";
|
|
3
|
+
import { breakdownCommands } from "../features/breakdown/commands.ts";
|
|
4
|
+
import { exportCommands } from "../features/export/commands.ts";
|
|
5
|
+
import { filterCommands } from "../features/filter/commands.ts";
|
|
6
|
+
import { pricingCommands } from "../features/pricing/commands.ts";
|
|
7
|
+
import { reportCommands } from "../features/report/commands.ts";
|
|
8
|
+
import { statusLineCommands } from "../features/status-line/commands.ts";
|
|
9
|
+
import type { Handlers } from "./host.ts";
|
|
10
|
+
|
|
11
|
+
export const handlers: Handlers = {
|
|
12
|
+
...statusLineCommands,
|
|
13
|
+
...budgetCommands,
|
|
14
|
+
...breakdownCommands,
|
|
15
|
+
...filterCommands,
|
|
16
|
+
...exportCommands,
|
|
17
|
+
...pricingCommands,
|
|
18
|
+
...reportCommands,
|
|
19
|
+
async invalid(cmd, { ctx }) {
|
|
20
|
+
ctx.ui.notify(cmd.message, "warning");
|
|
21
|
+
},
|
|
22
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** What a command handler gets, and the shape of a feature's command handlers. */
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { TokenBurnConfig } from "../core/config.ts";
|
|
4
|
+
import type { UsageFilter, UsageOptions } from "../core/select.ts";
|
|
5
|
+
import type { Command } from "./parse.ts";
|
|
6
|
+
|
|
7
|
+
export interface Host {
|
|
8
|
+
ctx: ExtensionContext;
|
|
9
|
+
/** Live settings: handlers mutate this, then call `persist()`. */
|
|
10
|
+
config: TokenBurnConfig;
|
|
11
|
+
/** Config filter + price table, merged with per-command filters. */
|
|
12
|
+
usageOpts(extra?: UsageFilter): UsageOptions;
|
|
13
|
+
/** Save the config and refresh the status line / panel. */
|
|
14
|
+
persist(): Promise<void>;
|
|
15
|
+
/** Info notification; mentions when tokenburn is disabled (settings still apply). */
|
|
16
|
+
note(message: string): void;
|
|
17
|
+
/** Drop a pending live refresh. */
|
|
18
|
+
cancelRefresh(): void;
|
|
19
|
+
/** Forget budget warnings already shown (after the threshold changed). */
|
|
20
|
+
resetWarnings(): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One handler per command kind; a feature exports the subset it owns. */
|
|
24
|
+
export type Handlers = { [K in Command["kind"]]: (cmd: Extract<Command, { kind: K }>, host: Host) => Promise<void> };
|
|
25
|
+
export type HandlersFor<K extends Command["kind"]> = Pick<Handlers, K>;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/** Command parsing (pure, unit-tested). Completions live in ./completions.ts. */
|
|
2
|
+
import { EXPORT_FORMATS, type ExportFormat } from "../features/export/export.ts";
|
|
3
|
+
import { FILTER_KEYS_LIST, type FilterKey, type PriceMode, type UsageFilter } from "../core/select.ts";
|
|
4
|
+
import { PERIOD_ALIASES, type GroupBy, type Period } from "../features/breakdown/query.ts";
|
|
5
|
+
import { STATUS_WINDOWS, type Window } from "../core/types.ts";
|
|
6
|
+
|
|
7
|
+
export const REPORT_VIEWS = ["day", "week", "month", "year", "all"] as const;
|
|
8
|
+
export type ReportView = (typeof REPORT_VIEWS)[number];
|
|
9
|
+
export const BUDGET_PERIODS = ["day", "week", "month"] as const;
|
|
10
|
+
|
|
11
|
+
export type Command =
|
|
12
|
+
| { kind: "report"; view: ReportView }
|
|
13
|
+
| { kind: "status"; window: Window }
|
|
14
|
+
| { kind: "cycle" }
|
|
15
|
+
| { kind: "window" }
|
|
16
|
+
| { kind: "cache" }
|
|
17
|
+
| { kind: "enable" }
|
|
18
|
+
| { kind: "disable" }
|
|
19
|
+
| { kind: "live" }
|
|
20
|
+
| { kind: "budget"; period: (typeof BUDGET_PERIODS)[number]; amount: number; isCost: boolean }
|
|
21
|
+
| { kind: "rate" }
|
|
22
|
+
| { kind: "hit" }
|
|
23
|
+
| { kind: "saved" }
|
|
24
|
+
| { kind: "session" }
|
|
25
|
+
| { kind: "table"; by: GroupBy; period: Period; filter: UsageFilter; limit?: number }
|
|
26
|
+
| { kind: "filter"; key?: FilterKey; value?: string; clear?: boolean }
|
|
27
|
+
| { kind: "export"; format: ExportFormat; period: Period; path?: string }
|
|
28
|
+
| { kind: "warn"; pct: number }
|
|
29
|
+
| { kind: "pricing"; mode?: PriceMode }
|
|
30
|
+
| { kind: "price"; model: string; input?: number; output?: number; cacheRead?: number; cacheWrite?: number; remove?: boolean }
|
|
31
|
+
| { kind: "invalid"; message: string };
|
|
32
|
+
|
|
33
|
+
export const USAGE =
|
|
34
|
+
"Usage: /tokenburn [hour|day|week|month|year|all] (status-bar period) ยท chart [day|week|month|year|all] ยท status <hour|today|week|month|year|total> ยท models|providers|projects [hour|day|week|month|year|all] [provider=x model=x project=x] ยท sessions [period] [n] ยท session ยท filter [provider|model|project] <value|all> ยท export <csv|json> [period] [path] ยท warn <pct|off> ยท pricing ยท price <model> <in> <out> ยท rate ยท hit ยท saved ยท cycle ยท window ยท budget <day|week|month> <tokens|$cost> ยท cache ยท live ยท enable ยท disable";
|
|
35
|
+
|
|
36
|
+
/** Words that pick the status-bar period: `day` shows today, `all` shows the all-time total. */
|
|
37
|
+
const WORD_TO_WINDOW: Record<string, Window> = {
|
|
38
|
+
hour: "hour",
|
|
39
|
+
day: "today",
|
|
40
|
+
today: "today",
|
|
41
|
+
week: "week",
|
|
42
|
+
month: "month",
|
|
43
|
+
year: "year",
|
|
44
|
+
all: "total",
|
|
45
|
+
total: "total",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Views the chart can show. */
|
|
49
|
+
const asView = (w: string | undefined): ReportView | undefined =>
|
|
50
|
+
w === "today" ? "day" : w === "total" ? "all" : (REPORT_VIEWS as readonly string[]).includes(w ?? "") ? (w as ReportView) : undefined;
|
|
51
|
+
|
|
52
|
+
const TABLE_WORDS: Record<string, GroupBy> = {
|
|
53
|
+
model: "model", models: "model", provider: "provider", providers: "provider",
|
|
54
|
+
project: "project", projects: "project", sessions: "session", top: "session",
|
|
55
|
+
};
|
|
56
|
+
const DEFAULT_TOP = 10;
|
|
57
|
+
|
|
58
|
+
/** `[period] [n] [key=value ...]` after a table command. */
|
|
59
|
+
function parseTableArgs(by: GroupBy, rest: string[]): Command {
|
|
60
|
+
let period: Period = "all";
|
|
61
|
+
let limit: number | undefined = by === "session" ? DEFAULT_TOP : undefined;
|
|
62
|
+
const filter: UsageFilter = {};
|
|
63
|
+
for (const raw of rest) {
|
|
64
|
+
const word = raw.toLowerCase();
|
|
65
|
+
const eq = raw.indexOf("=");
|
|
66
|
+
if (eq > 0) {
|
|
67
|
+
const key = word.slice(0, eq) as FilterKey;
|
|
68
|
+
const value = raw.slice(eq + 1);
|
|
69
|
+
if (!FILTER_KEYS_LIST.includes(key) || value === "") return { kind: "invalid", message: `Bad filter "${raw}". Use provider=<x> model=<x> project=<x>` };
|
|
70
|
+
filter[key] = value;
|
|
71
|
+
} else if (PERIOD_ALIASES[word]) period = PERIOD_ALIASES[word];
|
|
72
|
+
else if (/^\d+$/.test(word) && Number(word) > 0) limit = Number(word);
|
|
73
|
+
else return { kind: "invalid", message: `Unknown argument "${raw}". ${USAGE}` };
|
|
74
|
+
}
|
|
75
|
+
return { kind: "table", by, period, filter, limit };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function parseCommand(args: string | undefined): Command {
|
|
79
|
+
const rawParts = (args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
80
|
+
const [cmd, p1, p2] = rawParts.map((p) => p.toLowerCase());
|
|
81
|
+
if (!cmd) return { kind: "report", view: "day" };
|
|
82
|
+
|
|
83
|
+
// /tokenburn day|week|month|year|all โ what the bottom status line shows
|
|
84
|
+
const window = WORD_TO_WINDOW[cmd];
|
|
85
|
+
if (window) return { kind: "status", window };
|
|
86
|
+
|
|
87
|
+
switch (cmd) {
|
|
88
|
+
case "chart":
|
|
89
|
+
case "report": {
|
|
90
|
+
if (p1 === undefined) return { kind: "report", view: "day" };
|
|
91
|
+
const v = asView(p1);
|
|
92
|
+
return v ? { kind: "report", view: v } : { kind: "invalid", message: `Unknown chart "${p1}". ${USAGE}` };
|
|
93
|
+
}
|
|
94
|
+
case "status":
|
|
95
|
+
return (STATUS_WINDOWS as readonly string[]).includes(p1 ?? "")
|
|
96
|
+
? { kind: "status", window: p1 as Window }
|
|
97
|
+
: { kind: "invalid", message: `Usage: /tokenburn status <${STATUS_WINDOWS.join("|")}>` };
|
|
98
|
+
case "cycle":
|
|
99
|
+
return { kind: "cycle" };
|
|
100
|
+
case "window":
|
|
101
|
+
case "widget":
|
|
102
|
+
return { kind: "window" };
|
|
103
|
+
case "cache":
|
|
104
|
+
return { kind: "cache" };
|
|
105
|
+
case "enable":
|
|
106
|
+
case "on":
|
|
107
|
+
return { kind: "enable" };
|
|
108
|
+
case "disable":
|
|
109
|
+
case "off":
|
|
110
|
+
return { kind: "disable" };
|
|
111
|
+
case "live":
|
|
112
|
+
return { kind: "live" };
|
|
113
|
+
case "rate":
|
|
114
|
+
return { kind: "rate" };
|
|
115
|
+
case "hit":
|
|
116
|
+
return { kind: "hit" };
|
|
117
|
+
case "saved":
|
|
118
|
+
return { kind: "saved" };
|
|
119
|
+
case "session":
|
|
120
|
+
return { kind: "session" };
|
|
121
|
+
case "model":
|
|
122
|
+
case "models":
|
|
123
|
+
case "provider":
|
|
124
|
+
case "providers":
|
|
125
|
+
case "project":
|
|
126
|
+
case "projects":
|
|
127
|
+
case "sessions":
|
|
128
|
+
case "top":
|
|
129
|
+
return parseTableArgs(TABLE_WORDS[cmd], rawParts.slice(1));
|
|
130
|
+
case "filter": {
|
|
131
|
+
if (p1 === undefined) return { kind: "filter" };
|
|
132
|
+
if (p1 === "clear" || p1 === "all") return { kind: "filter", clear: true };
|
|
133
|
+
const value = rawParts.slice(2).join(" ");
|
|
134
|
+
if (!FILTER_KEYS_LIST.includes(p1 as FilterKey)) return { kind: "invalid", message: "Usage: /tokenburn filter [provider|model|project] <value | all>" };
|
|
135
|
+
if (value === "") return { kind: "invalid", message: `Usage: /tokenburn filter ${p1} <value | all>` };
|
|
136
|
+
return { kind: "filter", key: p1 as FilterKey, value: value.toLowerCase() === "all" ? undefined : value };
|
|
137
|
+
}
|
|
138
|
+
case "export": {
|
|
139
|
+
if (!(EXPORT_FORMATS as readonly string[]).includes(p1 ?? "")) return { kind: "invalid", message: "Usage: /tokenburn export <csv|json> [hour|day|week|month|year|all] [path]" };
|
|
140
|
+
const rest = rawParts.slice(2);
|
|
141
|
+
const period = rest[0] && PERIOD_ALIASES[rest[0].toLowerCase()];
|
|
142
|
+
return { kind: "export", format: p1 as ExportFormat, period: period || "all", path: (period ? rest.slice(1) : rest).join(" ") || undefined };
|
|
143
|
+
}
|
|
144
|
+
case "warn": {
|
|
145
|
+
const pct = p1 === "off" ? 0 : Number(p1);
|
|
146
|
+
if (!Number.isFinite(pct) || pct < 0 || pct > 100 || p1 === undefined) return { kind: "invalid", message: "Usage: /tokenburn warn <1-100 | off>" };
|
|
147
|
+
return { kind: "warn", pct };
|
|
148
|
+
}
|
|
149
|
+
case "pricing":
|
|
150
|
+
if (p1 === undefined) return { kind: "pricing" };
|
|
151
|
+
if (p1 === "mode" && (p2 === "fill" || p2 === "override")) return { kind: "pricing", mode: p2 };
|
|
152
|
+
return { kind: "invalid", message: "Usage: /tokenburn pricing [mode <fill|override>]" };
|
|
153
|
+
case "price": {
|
|
154
|
+
const model = rawParts[1];
|
|
155
|
+
if (model === undefined) return { kind: "invalid", message: "Usage: /tokenburn price <model | provider/model> <$in/M> <$out/M> [$cacheRead/M] [$cacheWrite/M] ยท price remove <model>" };
|
|
156
|
+
if (model.toLowerCase() === "remove" && rawParts[2]) return { kind: "price", model: rawParts[2], remove: true };
|
|
157
|
+
const n = rawParts.slice(2, 6).map((v) => Number(v.replace(/^\$/, "")));
|
|
158
|
+
if (n.length < 2 || n.some((v) => !Number.isFinite(v) || v < 0))
|
|
159
|
+
return { kind: "invalid", message: "Usage: /tokenburn price <model | provider/model> <$in/M> <$out/M> [$cacheRead/M] [$cacheWrite/M]" };
|
|
160
|
+
return { kind: "price", model, input: n[0], output: n[1], cacheRead: n[2], cacheWrite: n[3] };
|
|
161
|
+
}
|
|
162
|
+
case "budget": {
|
|
163
|
+
const isCost = (p2 ?? "").startsWith("$");
|
|
164
|
+
const amount = Number((p2 ?? "").replace(/^\$/, ""));
|
|
165
|
+
if (!(BUDGET_PERIODS as readonly string[]).includes(p1 ?? "") || !Number.isFinite(amount) || amount <= 0) {
|
|
166
|
+
return { kind: "invalid", message: "Usage: /tokenburn budget <day|week|month> <tokens | $cost>" };
|
|
167
|
+
}
|
|
168
|
+
return { kind: "budget", period: p1 as (typeof BUDGET_PERIODS)[number], amount, isCost };
|
|
169
|
+
}
|
|
170
|
+
default:
|
|
171
|
+
return { kind: "invalid", message: `Unknown option "${cmd}". ${USAGE}` };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/** Aggregation of parsed turns into period totals and time series. */
|
|
2
|
+
import { loadAll, type Turn } from "./parser.ts";
|
|
3
|
+
import { cacheValue, selectTurns, type UsageOptions } from "./select.ts";
|
|
4
|
+
import { bucketLabel, bucketStart, type Unit } from "./time.ts";
|
|
5
|
+
import type { PeriodStats, TokenBurnReport } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
const emptyStats = (): PeriodStats => ({
|
|
8
|
+
tokens: 0,
|
|
9
|
+
input: 0,
|
|
10
|
+
output: 0,
|
|
11
|
+
cacheRead: 0,
|
|
12
|
+
cacheWrite: 0,
|
|
13
|
+
cost: 0,
|
|
14
|
+
sessions: 0,
|
|
15
|
+
cacheBase: 0,
|
|
16
|
+
cacheActual: 0,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const HOUR_MS = 3_600_000;
|
|
20
|
+
|
|
21
|
+
/** Every file's turns with the filter and price table applied (files left empty are dropped). */
|
|
22
|
+
export async function loadSelected(root: string, opts?: UsageOptions): Promise<Turn[][]> {
|
|
23
|
+
const all = await loadAll(root);
|
|
24
|
+
if (!opts) return all;
|
|
25
|
+
return all.map((t) => selectTurns(t, opts)).filter((t) => t.length > 0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const turnTokens = (t: Turn, includeCache: boolean) =>
|
|
29
|
+
t.input + t.output + (includeCache ? t.cacheRead + t.cacheWrite : 0);
|
|
30
|
+
|
|
31
|
+
export async function collectUsage(
|
|
32
|
+
sessionsRoot: string,
|
|
33
|
+
now: Date = new Date(),
|
|
34
|
+
includeCache = true,
|
|
35
|
+
opts?: UsageOptions,
|
|
36
|
+
): Promise<TokenBurnReport> {
|
|
37
|
+
const startOfHour = now.getTime() - HOUR_MS;
|
|
38
|
+
const startOfDay = bucketStart(now.getTime(), "day");
|
|
39
|
+
const startOfWeek = bucketStart(now.getTime(), "week");
|
|
40
|
+
const startOfMonth = bucketStart(now.getTime(), "month");
|
|
41
|
+
const startOfYear = bucketStart(now.getTime(), "year");
|
|
42
|
+
|
|
43
|
+
const report: TokenBurnReport = {
|
|
44
|
+
hour: emptyStats(),
|
|
45
|
+
today: emptyStats(),
|
|
46
|
+
week: emptyStats(),
|
|
47
|
+
month: emptyStats(),
|
|
48
|
+
year: emptyStats(),
|
|
49
|
+
total: emptyStats(),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const add = (p: PeriodStats, t: Turn) => {
|
|
53
|
+
p.tokens += turnTokens(t, includeCache);
|
|
54
|
+
p.input += t.input;
|
|
55
|
+
p.output += t.output;
|
|
56
|
+
p.cacheRead += t.cacheRead;
|
|
57
|
+
p.cacheWrite += t.cacheWrite;
|
|
58
|
+
p.cost += t.cost;
|
|
59
|
+
const cv = cacheValue(t);
|
|
60
|
+
p.cacheBase += cv.base;
|
|
61
|
+
p.cacheActual += cv.actual;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
for (const turns of await loadSelected(sessionsRoot, opts)) {
|
|
65
|
+
let maxTs = 0;
|
|
66
|
+
for (const t of turns) {
|
|
67
|
+
if (t.ts > maxTs) maxTs = t.ts;
|
|
68
|
+
if (report.firstTs === undefined || t.ts < report.firstTs) report.firstTs = t.ts;
|
|
69
|
+
add(report.total, t);
|
|
70
|
+
if (t.ts >= startOfYear) add(report.year, t);
|
|
71
|
+
if (t.ts >= startOfMonth) add(report.month, t);
|
|
72
|
+
if (t.ts >= startOfWeek) add(report.week, t);
|
|
73
|
+
if (t.ts >= startOfDay) add(report.today, t);
|
|
74
|
+
if (t.ts >= startOfHour) add(report.hour, t);
|
|
75
|
+
}
|
|
76
|
+
if (turns.length > 0) report.total.sessions++;
|
|
77
|
+
if (maxTs >= startOfYear) report.year.sessions++;
|
|
78
|
+
if (maxTs >= startOfMonth) report.month.sessions++;
|
|
79
|
+
if (maxTs >= startOfWeek) report.week.sessions++;
|
|
80
|
+
if (maxTs >= startOfDay) report.today.sessions++;
|
|
81
|
+
if (maxTs >= startOfHour) report.hour.sessions++;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return report;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface SeriesPoint {
|
|
88
|
+
start: number;
|
|
89
|
+
label: string;
|
|
90
|
+
tokens: number;
|
|
91
|
+
cost: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const DEFAULT_COUNT: Record<Unit, number> = { day: 14, week: 8, month: 6, year: 5 };
|
|
95
|
+
|
|
96
|
+
/** Last `count` buckets ending at `now`, oldest first, empty buckets zero-filled. */
|
|
97
|
+
export async function collectSeries(
|
|
98
|
+
sessionsRoot: string,
|
|
99
|
+
unit: Unit,
|
|
100
|
+
count = DEFAULT_COUNT[unit],
|
|
101
|
+
now: Date = new Date(),
|
|
102
|
+
includeCache = true,
|
|
103
|
+
opts?: UsageOptions,
|
|
104
|
+
): Promise<SeriesPoint[]> {
|
|
105
|
+
const starts: number[] = [];
|
|
106
|
+
for (let i = count - 1; i >= 0; i--) {
|
|
107
|
+
const y = now.getFullYear();
|
|
108
|
+
const m = now.getMonth();
|
|
109
|
+
const day = now.getDate();
|
|
110
|
+
const anchor =
|
|
111
|
+
unit === "day"
|
|
112
|
+
? new Date(y, m, day - i)
|
|
113
|
+
: unit === "week"
|
|
114
|
+
? new Date(y, m, day - i * 7)
|
|
115
|
+
: unit === "year"
|
|
116
|
+
? new Date(y - i, 0, 1)
|
|
117
|
+
: new Date(y, m - i, 1);
|
|
118
|
+
starts.push(bucketStart(anchor.getTime(), unit));
|
|
119
|
+
}
|
|
120
|
+
const points = new Map<number, SeriesPoint>(
|
|
121
|
+
starts.map((start) => [start, { start, label: bucketLabel(start, unit), tokens: 0, cost: 0 }]),
|
|
122
|
+
);
|
|
123
|
+
for (const turns of await loadSelected(sessionsRoot, opts)) {
|
|
124
|
+
for (const t of turns) {
|
|
125
|
+
const pt = points.get(bucketStart(t.ts, unit));
|
|
126
|
+
if (!pt) continue;
|
|
127
|
+
pt.tokens += turnTokens(t, includeCache);
|
|
128
|
+
pt.cost += t.cost;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return starts.map((s) => points.get(s)!);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Whole history: monthly columns while it spans <= 18 months, yearly after that.
|
|
136
|
+
* Always at least 2 columns so the chart has a shape.
|
|
137
|
+
*/
|
|
138
|
+
export async function collectAllSeries(
|
|
139
|
+
sessionsRoot: string,
|
|
140
|
+
now: Date = new Date(),
|
|
141
|
+
includeCache = true,
|
|
142
|
+
opts?: UsageOptions,
|
|
143
|
+
): Promise<{ unit: Unit; series: SeriesPoint[] }> {
|
|
144
|
+
let first = now.getTime();
|
|
145
|
+
for (const turns of await loadSelected(sessionsRoot, opts)) for (const t of turns) if (t.ts < first) first = t.ts;
|
|
146
|
+
const f = new Date(first);
|
|
147
|
+
const months = (now.getFullYear() - f.getFullYear()) * 12 + (now.getMonth() - f.getMonth()) + 1;
|
|
148
|
+
if (months <= 18) {
|
|
149
|
+
const unit: Unit = "month";
|
|
150
|
+
return { unit, series: await collectSeries(sessionsRoot, unit, Math.max(2, months), now, includeCache, opts) };
|
|
151
|
+
}
|
|
152
|
+
const years = now.getFullYear() - f.getFullYear() + 1;
|
|
153
|
+
return { unit: "year", series: await collectSeries(sessionsRoot, "year", years, now, includeCache, opts) };
|
|
154
|
+
}
|