@timo972/cc-router 0.13.0-rc.3 → 0.13.0-rc.4
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/CHANGELOG.md +18 -0
- package/README.md +6 -4
- package/dist/cli/cmd-usage.js +4 -3
- package/dist/cli/index.js +1 -1
- package/dist/ui/UsageDashboard.js +112 -29
- package/dist/usage/client.js +14 -1
- package/dist/usage/query.js +16 -14
- package/dist/usage/types.js +16 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,24 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- `cc-router usage` charts and its daily grid switch between tokens and
|
|
14
|
+
estimated API spend (`s`) and narrow to input or output (`i`); every bucket,
|
|
15
|
+
model series and day in the report now carries frozen-rate USD per token
|
|
16
|
+
category. The model view (`l`) lists input, output, cache and spend per
|
|
17
|
+
model. Input, output and cache figures share one colour each across the
|
|
18
|
+
header, the cost line and the model view.
|
|
19
|
+
- The usage view fills the terminal like `cc-router status`, is indented one
|
|
20
|
+
column, and quits on `Esc`. Chart columns scale to the available width and
|
|
21
|
+
the chart shares the daily grid's right edge; the axis names its unit
|
|
22
|
+
instead of running day numbers together. Warnings sit below the grid.
|
|
23
|
+
- Subscription cost and net savings are no longer displayed in the usage
|
|
24
|
+
dashboard or the plain-text summary; the API-equivalent value stands alone,
|
|
25
|
+
broken down by input, output and cache. Both remain in `--json` output and
|
|
26
|
+
`usage subscription` still records costs. A client talking to an older
|
|
27
|
+
router shows the per-category spend as unavailable rather than zero.
|
|
28
|
+
|
|
11
29
|
### ⚠️ Breaking
|
|
12
30
|
|
|
13
31
|
- `cc-router accounts add-openai`, `login-openai`, `add-grok` and `login-grok`
|
package/README.md
CHANGED
|
@@ -109,12 +109,14 @@ it later. Adding more accounts is `cc-router accounts login`, re-signing one in
|
|
|
109
109
|
Per-platform token extraction, Codex CLI, Docker and everything else lives in
|
|
110
110
|
[the docs](docs/).
|
|
111
111
|
|
|
112
|
-
## Usage history and
|
|
112
|
+
## Usage history and API-equivalent cost
|
|
113
113
|
|
|
114
114
|
Run `cc-router usage` for persistent token history, provider/model stacked bars,
|
|
115
|
-
and a daily activity grid
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
and a daily activity grid, each switchable between tokens and what the recorded
|
|
116
|
+
tokens would have cost at standard API rates, and narrowable to input or output.
|
|
117
|
+
Switch between day, week, month and year; history survives service restarts.
|
|
118
|
+
Monthly subscription costs can be recorded for comparison in the `--json`
|
|
119
|
+
report:
|
|
118
120
|
|
|
119
121
|
```bash
|
|
120
122
|
cc-router usage subscription set personal --monthly-usd 100 --from 2026-09-01
|
package/dist/cli/cmd-usage.js
CHANGED
|
@@ -46,15 +46,16 @@ export function formatUsageText(report) {
|
|
|
46
46
|
const money = (n) => n === null ? "unavailable" : `${n < 0 ? "-" : ""}$${Math.abs(n).toFixed(2)}`;
|
|
47
47
|
return [
|
|
48
48
|
`Usage ${report.period}: ${report.start.slice(0, 10)} — ${report.end.slice(0, 10)} (UTC, end exclusive)`,
|
|
49
|
+
// Same four categories the dashboard shows, so the two surfaces read alike.
|
|
49
50
|
`Tokens: ${totalTokens(t).toLocaleString("en-US")} | input ${t.input} | output ${t.output} | cache read ${t.cacheRead} | cache write ${t.cacheWrite}`,
|
|
51
|
+
// Subscription cost and net savings are hidden for now; the API-equivalent
|
|
52
|
+
// value is the figure that stands on its own. Both remain in --json.
|
|
50
53
|
`API equivalent: ${money(c.pricedApiUsd)}${c.coverage.pricingComplete ? "" : " (partial; unpriced tokens excluded)"}`,
|
|
51
|
-
`Subscription: ${money(c.subscriptionUsd)}${c.coverage.subscriptionComplete ? "" : " (partial; configure missing costs)"}`,
|
|
52
|
-
`Net savings: ${money(c.savingsUsd)}${c.savingsPercent === null ? "" : ` (${c.savingsPercent.toFixed(1)}%)`}`,
|
|
53
54
|
...report.warnings.map(w => `Warning: ${w}`),
|
|
54
55
|
].join("\n");
|
|
55
56
|
}
|
|
56
57
|
export function registerUsage(program) {
|
|
57
|
-
const usage = program.command("usage").description("Persistent token usage and API-equivalent
|
|
58
|
+
const usage = program.command("usage").description("Persistent token usage and its API-equivalent cost")
|
|
58
59
|
.option("--period <period>", "day, week, month or year", "month")
|
|
59
60
|
.option("--date <date>", "Date within the selected period (YYYY-MM-DD, UTC)")
|
|
60
61
|
.option("--provider <provider>", "Filter Claude, OpenAI or Grok (repeatable)", (value, previous = []) => [...previous, value])
|
package/dist/cli/index.js
CHANGED
|
@@ -31,7 +31,7 @@ Examples:
|
|
|
31
31
|
$ cc-router start --reconfigure# Re-ask run preferences
|
|
32
32
|
$ cc-router stop # Stop proxy (offers to remove auto-start / config)
|
|
33
33
|
$ cc-router status # Live dashboard with account stats
|
|
34
|
-
$ cc-router usage # Persistent token usage and
|
|
34
|
+
$ cc-router usage # Persistent token usage and its API-equivalent cost
|
|
35
35
|
$ cc-router models list # List dynamically discovered provider models
|
|
36
36
|
$ cc-router logs # View proxy logs (background mode)
|
|
37
37
|
$ cc-router accounts list # Show Claude, ChatGPT, and Grok accounts
|
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
3
|
import { Box, Text, useApp, useInput, useStdout } from "ink";
|
|
4
|
-
import { USAGE_PROVIDERS, totalTokens } from "../usage/types.js";
|
|
4
|
+
import { USAGE_PROVIDERS, addSpend, addTokens, totalSpend, totalTokens, zeroSpend, zeroTokens } from "../usage/types.js";
|
|
5
5
|
import { activityLevel, chartColumns, formatTokens, heatmapWeeks, legendColor, legendGlyph, PROVIDER_COLORS, PROVIDER_LABELS } from "./usage-chart.js";
|
|
6
6
|
const PERIODS = ["day", "week", "month", "year"];
|
|
7
7
|
const HEAT = ["#42474e", "#466c50", "#5b9565", "#82c489", "#b7efba"];
|
|
8
|
+
const FACETS = ["sum", "input", "output"];
|
|
9
|
+
const FACET_LABELS = { sum: "All", input: "Input", output: "Output" };
|
|
10
|
+
/** One colour per token category, shared by the totals, the cost line, the mode indicator and the inspector. */
|
|
11
|
+
const CATEGORY_COLORS = { input: "#8dcc9a", output: "#db9bac", cache: "#e1ce8a" };
|
|
12
|
+
const MAX_COLUMN_WIDTH = 12;
|
|
8
13
|
const money = (n) => n === null ? "unavailable" : `${n < 0 ? "-" : ""}$${Math.abs(n).toFixed(2)}`;
|
|
14
|
+
/** Axis-sized money: two decimals until the scale suffixes read better. */
|
|
15
|
+
const compactMoney = (n) => n >= 1000 ? `$${formatTokens(n)}` : money(n);
|
|
16
|
+
export function measureUsage(tokens, usd, spend, facet) {
|
|
17
|
+
if (spend)
|
|
18
|
+
return facet === "sum" ? totalSpend(usd) : usd[facet];
|
|
19
|
+
return facet === "sum" ? totalTokens(tokens) : tokens[facet];
|
|
20
|
+
}
|
|
9
21
|
export function shiftUsageDate(query, direction) {
|
|
10
22
|
const d = new Date(`${query.date ?? new Date().toISOString().slice(0, 10)}T00:00:00Z`);
|
|
11
23
|
if (query.period === "month") {
|
|
@@ -29,6 +41,8 @@ export function UsageDashboard({ load, initialQuery = { period: "month" }, onExi
|
|
|
29
41
|
const [error, setError] = useState();
|
|
30
42
|
const [loading, setLoading] = useState(true);
|
|
31
43
|
const [models, setModels] = useState(false);
|
|
44
|
+
const [spend, setSpend] = useState(false);
|
|
45
|
+
const [facet, setFacet] = useState("sum");
|
|
32
46
|
const [gridFocus, setGridFocus] = useState(false);
|
|
33
47
|
const [helpOpen, setHelpOpen] = useState(false);
|
|
34
48
|
const [modelInspector, setModelInspector] = useState(false);
|
|
@@ -68,13 +82,35 @@ export function UsageDashboard({ load, initialQuery = { period: "month" }, onExi
|
|
|
68
82
|
void poll();
|
|
69
83
|
return () => { cancelled = true; clearTimeout(timer); };
|
|
70
84
|
}, [load, query]);
|
|
71
|
-
|
|
85
|
+
// Period totals per model: the inspector shows input/output/cache/spend, not a single bucket.
|
|
86
|
+
const fullModels = [...(report?.buckets ?? []).reduce((acc, bucket) => {
|
|
87
|
+
for (const series of Object.values(bucket.series)) {
|
|
88
|
+
const key = `${series.provider}:${series.model}`;
|
|
89
|
+
const entry = acc.get(key) ?? { provider: series.provider, model: series.model, tokens: zeroTokens(), usd: zeroSpend() };
|
|
90
|
+
addTokens(entry.tokens, series.tokens);
|
|
91
|
+
addSpend(entry.usd, series.usd);
|
|
92
|
+
acc.set(key, entry);
|
|
93
|
+
}
|
|
94
|
+
return acc;
|
|
95
|
+
}, new Map()).values()].sort((a, b) => `${a.provider}:${a.model}`.localeCompare(`${b.provider}:${b.model}`));
|
|
72
96
|
useInput((input, key) => {
|
|
73
97
|
if (input === "q" || (key.ctrl && input === "c")) {
|
|
74
98
|
onExit?.();
|
|
75
99
|
exit();
|
|
76
100
|
return;
|
|
77
101
|
}
|
|
102
|
+
// Escape mirrors status: it leaves an overlay first and only quits from the main view.
|
|
103
|
+
if (key.escape) {
|
|
104
|
+
if (helpOpen)
|
|
105
|
+
setHelpOpen(false);
|
|
106
|
+
else if (modelInspector)
|
|
107
|
+
setModelInspector(false);
|
|
108
|
+
else {
|
|
109
|
+
onExit?.();
|
|
110
|
+
exit();
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
78
114
|
if (input === "?") {
|
|
79
115
|
setHelpOpen(value => !value);
|
|
80
116
|
return;
|
|
@@ -94,6 +130,10 @@ export function UsageDashboard({ load, initialQuery = { period: "month" }, onExi
|
|
|
94
130
|
setQuery(q => ({ ...q, period: PERIODS[(PERIODS.indexOf(q.period) + (key.shift ? 3 : 1)) % 4] }));
|
|
95
131
|
if (input === "m")
|
|
96
132
|
setModels(value => !value);
|
|
133
|
+
if (input === "s")
|
|
134
|
+
setSpend(value => !value);
|
|
135
|
+
if (input === "i")
|
|
136
|
+
setFacet(value => FACETS[(FACETS.indexOf(value) + 1) % FACETS.length]);
|
|
97
137
|
if (input === "g")
|
|
98
138
|
setGridFocus(value => !value);
|
|
99
139
|
if (input === "t") {
|
|
@@ -119,58 +159,97 @@ export function UsageDashboard({ load, initialQuery = { period: "month" }, onExi
|
|
|
119
159
|
const width = Math.max(12, size.width - 2);
|
|
120
160
|
const height = Math.max(4, size.height - 1);
|
|
121
161
|
const line = (content, key) => _jsx(Text, { wrap: "truncate-end", children: content }, key);
|
|
162
|
+
const measure = (tokens, usd) => measureUsage(tokens, usd, spend, facet);
|
|
163
|
+
const formatMeasure = (value) => spend ? compactMoney(value) : formatTokens(value);
|
|
164
|
+
const unit = `${facet === "sum" ? "" : `${facet} `}${spend ? "spend" : "tokens"}`;
|
|
165
|
+
const describe = (value) => `${spend ? money(value) : value.toLocaleString("en-US")} ${unit}`;
|
|
166
|
+
// A fixed frame height, as the status dashboard uses, so the view always fills the terminal
|
|
167
|
+
// and whatever was on screen before scrolls away instead of sitting above a short report.
|
|
168
|
+
// One column of left padding, like the status dashboard; `width` already leaves room for it.
|
|
122
169
|
if (helpOpen)
|
|
123
|
-
return _jsx(Box, { width: width, flexDirection: "column", children: [
|
|
124
|
-
"Usage controls (UTC)", "Tab / Shift+Tab Period tab", "← → Previous / next period", "t Current period", "1/2/3 Claude/OpenAI/Grok", "m Model stacks", "l Full model names", "g Day focus / chart", "↑ ↓ Inspect day (grid)", "← → Inspect week (grid)", "? Return to usage", "q quit",
|
|
170
|
+
return _jsx(Box, { width: width, height: height, paddingLeft: 1, flexDirection: "column", children: [
|
|
171
|
+
"Usage controls (UTC)", "Esc Back / quit", "Tab / Shift+Tab Period tab", "← → Previous / next period", "t Current period", "1/2/3 Claude/OpenAI/Grok", "m Model stacks", "s Tokens / spend", "i All / input / output", "l Full model names", "g Day focus / chart", "↑ ↓ Inspect day (grid)", "← → Inspect week (grid)", "? Return to usage", "q quit",
|
|
125
172
|
].slice(0, height).map((text, i) => line(text, `help${i}`)) });
|
|
126
173
|
if (modelInspector) {
|
|
127
174
|
const selected = fullModels[modelIndex % Math.max(1, fullModels.length)];
|
|
128
175
|
const name = selected?.model ?? "No model usage in this period";
|
|
129
176
|
const chunks = Array.from({ length: Math.ceil(name.length / width) }, (_, i) => name.slice(i * width, (i + 1) * width));
|
|
130
|
-
|
|
177
|
+
const detail = selected ? [
|
|
178
|
+
line(_jsxs(_Fragment, { children: [_jsxs(Text, { color: CATEGORY_COLORS.input, children: ["Input ", formatTokens(selected.tokens.input)] }), " \u00B7 ", _jsxs(Text, { color: CATEGORY_COLORS.output, children: ["Output ", formatTokens(selected.tokens.output)] }), " \u00B7 ", _jsxs(Text, { color: CATEGORY_COLORS.cache, children: ["Cache ", formatTokens(selected.tokens.cacheRead), " read / ", formatTokens(selected.tokens.cacheWrite), " write"] })] }), "model-tokens"),
|
|
179
|
+
report?.spendAvailable === false
|
|
180
|
+
? line(_jsx(Text, { dimColor: true, children: "Spend unavailable until the router restarts" }), "model-spend")
|
|
181
|
+
: line(_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, children: ["Spend ", money(totalSpend(selected.usd))] }), " \u00B7 ", _jsxs(Text, { color: CATEGORY_COLORS.input, children: ["Input ", money(selected.usd.input)] }), " \u00B7 ", _jsxs(Text, { color: CATEGORY_COLORS.output, children: ["Output ", money(selected.usd.output)] }), " \u00B7 ", _jsxs(Text, { color: CATEGORY_COLORS.cache, children: ["Cache ", money(selected.usd.cacheRead + selected.usd.cacheWrite)] })] }), "model-spend"),
|
|
182
|
+
] : [];
|
|
183
|
+
return _jsxs(Box, { width: width, height: height, paddingLeft: 1, flexDirection: "column", children: [line(`Model ${fullModels.length ? modelIndex % fullModels.length + 1 : 0}/${fullModels.length} · ${selected ? PROVIDER_LABELS[selected.provider] : ""}`, "model-title"), chunks.slice(0, Math.max(1, height - 3 - detail.length)).map((text, i) => line(text, `model-name${i}`)), detail, line("↑↓ browse · l back · q quit", "model-help")] });
|
|
131
184
|
}
|
|
185
|
+
// Spacer lines between sections whenever the viewport can afford them.
|
|
186
|
+
const roomy = height >= 30;
|
|
132
187
|
const lines = [];
|
|
188
|
+
const gap = (key) => { if (roomy)
|
|
189
|
+
lines.push(line(" ", key)); };
|
|
133
190
|
lines.push(line(_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, color: "cyan", children: "cc-router usage" }), " ", report?.start.slice(0, 10) ?? query.date ?? "Current", " \u00B7 UTC", loading ? " · loading" : ""] }), "title"));
|
|
134
191
|
lines.push(line(_jsx(_Fragment, { children: PERIODS.map(period => _jsxs(Text, { bold: period === query.period, inverse: period === query.period, children: [" ", period[0].toUpperCase() + period.slice(1), " "] }, period)) }), "tabs"));
|
|
192
|
+
gap("gap-tabs");
|
|
135
193
|
if (report) {
|
|
136
194
|
const { totals, costs } = report;
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
195
|
+
const spendTotals = report.buckets.reduce((acc, bucket) => { addSpend(acc, bucket.usd); return acc; }, zeroSpend());
|
|
196
|
+
const spendKnown = report.spendAvailable !== false;
|
|
197
|
+
const cacheTokens = _jsxs(Text, { color: CATEGORY_COLORS.cache, children: ["Cache ", _jsx(Text, { bold: true, children: formatTokens(totals.cacheRead) }), " read / ", _jsx(Text, { bold: true, children: formatTokens(totals.cacheWrite) }), " write"] });
|
|
198
|
+
lines.push(line(_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: formatTokens(totalTokens(totals)) }), " tokens ", _jsxs(Text, { color: CATEGORY_COLORS.input, children: ["Input ", _jsx(Text, { bold: true, children: formatTokens(totals.input) })] }), " ", _jsxs(Text, { color: CATEGORY_COLORS.output, children: ["Output ", _jsx(Text, { bold: true, children: formatTokens(totals.output) })] }), width >= 68 ? _jsxs(_Fragment, { children: [" ", cacheTokens] }) : null] }), "tokens"));
|
|
199
|
+
// Narrow terminals truncated the cache figures off the end of the line; give them their own.
|
|
140
200
|
if (width < 68)
|
|
141
|
-
lines.push(line(
|
|
201
|
+
lines.push(line(cacheTokens, "cache-tokens"));
|
|
202
|
+
lines.push(line(_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, children: ["API cost ", money(costs.pricedApiUsd)] }), costs.coverage.pricingComplete ? "" : _jsx(Text, { color: "yellow", children: " (partial)" }), width < 68 ? null : spendKnown
|
|
203
|
+
? _jsxs(_Fragment, { children: [" ", _jsxs(Text, { color: CATEGORY_COLORS.input, children: ["Input ", _jsx(Text, { bold: true, children: money(spendTotals.input) })] }), " ", _jsxs(Text, { color: CATEGORY_COLORS.output, children: ["Output ", _jsx(Text, { bold: true, children: money(spendTotals.output) })] }), " ", _jsxs(Text, { color: CATEGORY_COLORS.cache, children: ["Cache ", _jsx(Text, { bold: true, children: money(spendTotals.cacheRead + spendTotals.cacheWrite) })] })] })
|
|
204
|
+
: _jsx(Text, { dimColor: true, children: " Input / output / cache unavailable until the router restarts" })] }), "costs"));
|
|
205
|
+
gap("gap-totals");
|
|
142
206
|
}
|
|
143
|
-
lines.push(line(_jsxs(_Fragment, { children: [USAGE_PROVIDERS.map((provider, i) => _jsxs(Text, { color: PROVIDER_COLORS[provider], dimColor: query.providers !== undefined && !query.providers.includes(provider), children: [i + 1, " ", query.providers === undefined || query.providers.includes(provider) ? "■" : "□", " ", PROVIDER_LABELS[provider], " "] }, provider)),
|
|
207
|
+
lines.push(line(_jsxs(_Fragment, { children: [USAGE_PROVIDERS.map((provider, i) => _jsxs(Text, { color: PROVIDER_COLORS[provider], dimColor: query.providers !== undefined && !query.providers.includes(provider), children: [i + 1, " ", query.providers === undefined || query.providers.includes(provider) ? "■" : "□", " ", PROVIDER_LABELS[provider], " "] }, provider)), _jsxs(Text, { dimColor: true, children: [models ? "Models" : "Providers", " \u00B7 "] }), _jsx(Text, { color: spend ? "yellow" : "cyan", children: spend ? "Spend" : "Tokens" }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), _jsx(Text, { color: facet === "sum" ? undefined : CATEGORY_COLORS[facet], dimColor: facet === "sum", children: FACET_LABELS[facet] })] }), "providers"));
|
|
144
208
|
const warning = error ?? report?.warnings[0];
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
209
|
+
const notice = warning
|
|
210
|
+
? line(_jsxs(Text, { color: "yellow", children: [error ? "Error" : "Partial", ": ", warning, report && report.warnings.length > 1 ? ` (+${report.warnings.length - 1})` : ""] }), "warning")
|
|
211
|
+
: report && totalTokens(report.totals) === 0
|
|
212
|
+
? line(_jsx(Text, { dimColor: true, children: query.providers?.length === 0 ? "No providers selected. Press 1–3 to include one." : "No tokens recorded in this period. History begins with tracking." }), "empty")
|
|
213
|
+
: undefined;
|
|
149
214
|
if (report) {
|
|
150
215
|
const showBoth = height >= 25;
|
|
151
216
|
const showGrid = showBoth || gridFocus;
|
|
152
|
-
const
|
|
217
|
+
const gapLines = roomy ? 1 : 0;
|
|
218
|
+
// Everything that must still fit under the chart: axis, legend, grid, notice, help and the spacers between them.
|
|
219
|
+
const reserved = 2 + (showGrid ? 9 + gapLines : 0) + gapLines + (notice ? 1 : 0) + 1;
|
|
220
|
+
const chartHeight = Math.max(2, Math.min(8, height - lines.length - gapLines - reserved));
|
|
221
|
+
// The grid is at most a year of weeks wide; the chart shares that right edge so the two read as one block.
|
|
222
|
+
const weeks = heatmapWeeks(report.days.map(day => day.date));
|
|
223
|
+
const span = Math.max(1, Math.min(weeks.length, Math.floor((width - 4) / 2)));
|
|
224
|
+
const gridWidth = 4 + span * 2;
|
|
153
225
|
if (showBoth || !gridFocus) {
|
|
226
|
+
gap("gap-chart");
|
|
227
|
+
// Few buckets (a week has seven) get wide columns so the bars fill the row and every label has room.
|
|
228
|
+
const plotWidth = Math.max(2, Math.min(width, gridWidth) - 8);
|
|
229
|
+
const columnWidth = Math.max(2, Math.min(MAX_COLUMN_WIDTH, Math.floor(plotWidth / Math.max(1, report.buckets.length))));
|
|
154
230
|
const chart = chartColumns(report.buckets.map(b => ({
|
|
155
231
|
label: query.period === "day" ? b.start.slice(11, 13) : query.period === "year" ? b.start.slice(5, 7) : b.start.slice(8, 10),
|
|
156
|
-
series: Object.values(b.series).map(s => ({ ...s, tokens:
|
|
157
|
-
})), Math.max(1, Math.floor(
|
|
232
|
+
series: Object.values(b.series).map(s => ({ ...s, tokens: measure(s.tokens, s.usd) })),
|
|
233
|
+
})), Math.max(1, Math.floor(plotWidth / columnWidth)), chartHeight, models, models ? Math.max(2, Math.floor(width / 24)) : 3);
|
|
158
234
|
const colors = new Map(chart.legend.map((s, i) => [s.key, legendColor(s, i, models)]));
|
|
159
235
|
const glyphs = new Map(chart.legend.map((s, i) => [s.key, legendGlyph(s, i, models)]));
|
|
236
|
+
// Wide columns keep one blank cell between bars; two-cell columns stay flush as before.
|
|
237
|
+
const fill = columnWidth >= 3 ? columnWidth - 1 : columnWidth;
|
|
160
238
|
for (let row = 0; row < chartHeight; row++)
|
|
161
|
-
lines.push(line(_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: [(row === 0 ?
|
|
162
|
-
|
|
239
|
+
lines.push(line(_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: [(row === 0 ? formatMeasure(chart.max) : row === chartHeight - 1 ? "0" : "").padStart(6), " \u2502"] }), chart.columns.map((col, i) => _jsx(Text, { color: col.cells[row] ? colors.get(col.cells[row]) : undefined, children: (col.cells[row] ? glyphs.get(col.cells[row]).repeat(fill) : " ".repeat(fill)).padEnd(columnWidth) }, i))] }), `bar${row}`));
|
|
240
|
+
const labelStride = columnWidth >= 3 ? 1 : Math.max(1, Math.ceil(chart.columns.length / 8));
|
|
241
|
+
const axisUnit = query.period === "day" ? "hour" : query.period === "year" ? "month" : "day";
|
|
242
|
+
lines.push(line(_jsxs(Text, { dimColor: true, children: [" └", chart.columns.map((col, i) => i % labelStride === 0 ? col.label.padStart(2).slice(-2).padEnd(columnWidth) : " ".repeat(columnWidth)).join("").trimEnd(), " ", axisUnit] }), "axis"));
|
|
163
243
|
const labelWidth = Math.max(5, Math.floor(width / Math.max(1, chart.legend.length)) - 4);
|
|
164
244
|
lines.push(line(_jsx(_Fragment, { children: chart.legend.map(s => _jsxs(Text, { color: colors.get(s.key), children: [glyphs.get(s.key), " ", s.label.length > labelWidth ? `${s.label.slice(0, Math.ceil((labelWidth - 1) / 2))}…${s.label.slice(-Math.floor((labelWidth - 1) / 2))}` : s.label, " "] }, s.key)) }), "legend"));
|
|
165
245
|
}
|
|
166
|
-
if (showGrid && height - lines.length >= 10) {
|
|
246
|
+
if (showGrid && height - lines.length >= 10 + gapLines) {
|
|
247
|
+
gap("gap-grid");
|
|
167
248
|
const byDate = new Map(report.days.map(day => [day.date, day]));
|
|
168
|
-
const weeks = heatmapWeeks(report.days.map(day => day.date));
|
|
169
|
-
const span = Math.max(1, Math.floor((width - 4) / 2));
|
|
170
249
|
const focusedWeek = weeks.findIndex(week => week.includes(focused));
|
|
171
250
|
const firstWeek = Math.max(0, Math.min(weeks.length - span, focusedWeek - Math.floor(span / 2)));
|
|
172
251
|
const visible = weeks.slice(firstWeek, firstWeek + span);
|
|
173
|
-
const max = Math.max(0, ...report.days.map(day =>
|
|
252
|
+
const max = Math.max(0, ...report.days.map(day => measure(day.tokens, day.usd)));
|
|
174
253
|
let lastMonth = "";
|
|
175
254
|
const months = visible.map(week => {
|
|
176
255
|
const date = week.find(Boolean) ?? "";
|
|
@@ -179,7 +258,7 @@ export function UsageDashboard({ load, initialQuery = { period: "month" }, onExi
|
|
|
179
258
|
lastMonth = month;
|
|
180
259
|
return label;
|
|
181
260
|
}).join("");
|
|
182
|
-
lines.push(line(_jsxs(Text, { dimColor: true, children: [" ", months, " ", gridFocus ? "[day focus]" :
|
|
261
|
+
lines.push(line(_jsxs(Text, { dimColor: true, children: [" ", months, " ", gridFocus ? "[day focus]" : `[daily ${spend ? "spend" : "tokens"}${facet === "sum" ? "" : ` · ${facet}`}]`] }), "months"));
|
|
183
262
|
for (let row = 0; row < 7; row++)
|
|
184
263
|
lines.push(line(_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: [["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"][row], " "] }), visible.map((week, i) => {
|
|
185
264
|
const date = week[row];
|
|
@@ -187,22 +266,26 @@ export function UsageDashboard({ load, initialQuery = { period: "month" }, onExi
|
|
|
187
266
|
if (!day)
|
|
188
267
|
return _jsx(Text, { children: " " }, i);
|
|
189
268
|
const unknown = day.coverage === "future" || day.coverage === "untracked";
|
|
190
|
-
return _jsx(Text, { inverse: gridFocus && date === focused, bold: day.selected, color: unknown ? "gray" : HEAT[activityLevel(
|
|
269
|
+
return _jsx(Text, { inverse: gridFocus && date === focused, bold: day.selected, color: unknown ? "gray" : HEAT[activityLevel(measure(day.tokens, day.usd), max)], children: day.coverage === "future" ? "· " : day.coverage === "untracked" ? "? " : day.coverage === "partial" ? "▧ " : "■ " }, i);
|
|
191
270
|
})] }), `heat${row}`));
|
|
192
271
|
const selected = byDate.get(focused);
|
|
193
|
-
lines.push(line(_jsxs(Text, { dimColor: true, children: [selected ? `${focused} ${
|
|
272
|
+
lines.push(line(_jsxs(Text, { dimColor: true, children: [selected ? `${focused} ${describe(measure(selected.tokens, selected.usd))} · ${selected.coverage}` : `Daily ${unit}`, " Less \u2591\u2592\u2593\u2588 More"] }), "day"));
|
|
194
273
|
}
|
|
195
274
|
else if (showGrid) {
|
|
196
275
|
const selected = report.days.find(day => day.date === focused);
|
|
197
276
|
lines.push(line(_jsx(Text, { bold: true, children: "Compact day focus \u00B7 arrows inspect" }), "compact-grid"));
|
|
198
277
|
if (selected)
|
|
199
|
-
lines.push(line(`${focused} ${
|
|
278
|
+
lines.push(line(`${focused} ${describe(measure(selected.tokens, selected.usd))}`, "compact-inspector"));
|
|
200
279
|
lines.push(line(`${selected?.coverage ?? "unavailable"} · ? untracked · · future`, "compact-coverage"));
|
|
201
280
|
}
|
|
202
281
|
}
|
|
282
|
+
if (notice) {
|
|
283
|
+
gap("gap-notice");
|
|
284
|
+
lines.push(notice);
|
|
285
|
+
}
|
|
203
286
|
if (!report && !loading && !error)
|
|
204
287
|
lines.push(line("No usage history available.", "nohistory"));
|
|
205
|
-
const help = width >=
|
|
288
|
+
const help = width >= 96 ? "q quit · ? help · Tab period · ←→ move · t today · 1–3 · m models · s spend · i in/out · g grid · l names" : width >= 38 ? "q quit · ? help · Tab ←→ t 1–3 m s i g l" : "q quit · ? help";
|
|
206
289
|
// Explicit viewport budget prevents Ink from scrolling controls offscreen on resize.
|
|
207
|
-
return _jsxs(Box, { width: width, flexDirection: "column", children: [lines.slice(0, height - 1), line(_jsx(Text, { dimColor: true, children: help }), "help")] });
|
|
290
|
+
return _jsxs(Box, { width: width, height: height, paddingLeft: 1, flexDirection: "column", children: [lines.slice(0, height - 1), line(_jsx(Text, { dimColor: true, children: help }), "help")] });
|
|
208
291
|
}
|
package/dist/usage/client.js
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { UsageStore } from "./store.js";
|
|
4
4
|
import { queryRuntimeUsage } from "./runtime.js";
|
|
5
|
-
import { utcTimestamp, usageProvider, validateAccount, validateTokens } from "./types.js";
|
|
5
|
+
import { utcTimestamp, usageProvider, validateAccount, validateSpend, validateTokens, zeroSpend } from "./types.js";
|
|
6
6
|
const clean = (value, max = 256) => {
|
|
7
7
|
if (typeof value !== "string" || value.length > max || /[\p{Cc}\p{Cf}]/u.test(value))
|
|
8
8
|
throw new Error("Invalid usage text from server");
|
|
@@ -21,16 +21,25 @@ export function validateUsageReport(value) {
|
|
|
21
21
|
if (!Array.isArray(r.buckets) || r.buckets.length > 366 || !Array.isArray(r.days) || r.days.length > 366
|
|
22
22
|
|| !Array.isArray(r.accounts) || r.accounts.length > 10_000 || !Array.isArray(r.warnings) || r.warnings.length > 100)
|
|
23
23
|
throw new Error("Invalid usage report size");
|
|
24
|
+
// A router from before per-bucket spend omits `usd`; treat that as zero rather than refusing its history,
|
|
25
|
+
// but flag it so the dashboard shows "unavailable" instead of a false $0.00 split.
|
|
26
|
+
let spendMissing = false;
|
|
27
|
+
const spend = (value) => { if (value === undefined) {
|
|
28
|
+
spendMissing = true;
|
|
29
|
+
return zeroSpend();
|
|
30
|
+
} return validateSpend(value); };
|
|
24
31
|
for (const b of [...r.buckets, ...r.days]) {
|
|
25
32
|
utcTimestamp(b.start);
|
|
26
33
|
utcTimestamp(b.end);
|
|
27
34
|
validateTokens(b.tokens);
|
|
35
|
+
b.usd = spend(b.usd);
|
|
28
36
|
if (!b.series || typeof b.series !== "object" || Object.keys(b.series).length > 100)
|
|
29
37
|
throw new Error("Invalid usage series");
|
|
30
38
|
for (const s of Object.values(b.series)) {
|
|
31
39
|
usageProvider(s.provider);
|
|
32
40
|
clean(s.model);
|
|
33
41
|
validateTokens(s.tokens);
|
|
42
|
+
s.usd = spend(s.usd);
|
|
34
43
|
}
|
|
35
44
|
}
|
|
36
45
|
for (const d of r.days) {
|
|
@@ -52,6 +61,10 @@ export function validateUsageReport(value) {
|
|
|
52
61
|
for (const key of ["pricingComplete", "subscriptionComplete", "trackingComplete", "persistenceHealthy"])
|
|
53
62
|
if (typeof r.costs.coverage[key] !== "boolean")
|
|
54
63
|
throw new Error("Invalid coverage");
|
|
64
|
+
if (spendMissing) {
|
|
65
|
+
r.spendAvailable = false;
|
|
66
|
+
r.warnings.push("This router predates per-category spend; restart it (cc-router start) to see input, output and cache costs.");
|
|
67
|
+
}
|
|
55
68
|
return r;
|
|
56
69
|
}
|
|
57
70
|
function transportFailure(error) {
|
package/dist/usage/query.js
CHANGED
|
@@ -1,32 +1,34 @@
|
|
|
1
|
-
import { addTokens, object, USAGE_PROVIDERS, utcTimestamp, usageProvider, zeroTokens } from "./types.js";
|
|
1
|
+
import { addSpend, addTokens, object, totalSpend, USAGE_PROVIDERS, utcTimestamp, usageProvider, zeroSpend, zeroTokens } from "./types.js";
|
|
2
2
|
import { prorateSubscriptions } from "./subscriptions.js";
|
|
3
3
|
const DAY = 86_400_000;
|
|
4
4
|
const HOUR = 3_600_000;
|
|
5
5
|
const MODEL_LIMIT = 16; // Per provider, including Other. Bounds all chart/heatmap series.
|
|
6
6
|
function iso(ms) { return new Date(ms).toISOString(); }
|
|
7
|
-
function newBucket(start, end) { return { start: iso(start), end: iso(end), tokens: zeroTokens(), series: {} }; }
|
|
8
|
-
function addToBucket(bucket, entry, model) {
|
|
7
|
+
function newBucket(start, end) { return { start: iso(start), end: iso(end), tokens: zeroTokens(), usd: zeroSpend(), series: {} }; }
|
|
8
|
+
function addToBucket(bucket, entry, model, usd) {
|
|
9
9
|
addTokens(bucket.tokens, entry.tokens);
|
|
10
|
+
addSpend(bucket.usd, usd);
|
|
10
11
|
const key = JSON.stringify([entry.provider, model]);
|
|
11
|
-
const series = bucket.series[key] ??= { provider: entry.provider, model, tokens: zeroTokens() };
|
|
12
|
+
const series = bucket.series[key] ??= { provider: entry.provider, model, tokens: zeroTokens(), usd: zeroSpend() };
|
|
12
13
|
addTokens(series.tokens, entry.tokens);
|
|
14
|
+
addSpend(series.usd, usd);
|
|
13
15
|
}
|
|
14
16
|
function price(entry) {
|
|
15
17
|
const { tokens, rates } = entry;
|
|
16
18
|
const categories = [
|
|
17
|
-
[tokens.input, rates?.input], [tokens.output, rates?.output], [tokens.cacheRead, rates?.cacheRead],
|
|
18
|
-
[tokens.cacheWrite - tokens.cacheWrite5m - tokens.cacheWrite1h, rates?.cacheWrite],
|
|
19
|
-
[tokens.cacheWrite5m, rates?.cacheWrite5m], [tokens.cacheWrite1h, rates?.cacheWrite1h],
|
|
19
|
+
["input", tokens.input, rates?.input], ["output", tokens.output, rates?.output], ["cacheRead", tokens.cacheRead, rates?.cacheRead],
|
|
20
|
+
["cacheWrite", tokens.cacheWrite - tokens.cacheWrite5m - tokens.cacheWrite1h, rates?.cacheWrite],
|
|
21
|
+
["cacheWrite", tokens.cacheWrite5m, rates?.cacheWrite5m], ["cacheWrite", tokens.cacheWrite1h, rates?.cacheWrite1h],
|
|
20
22
|
];
|
|
21
|
-
|
|
23
|
+
const usd = zeroSpend();
|
|
22
24
|
let priced = 0;
|
|
23
25
|
let unpriced = 0;
|
|
24
|
-
for (const [count, rate] of categories) {
|
|
26
|
+
for (const [category, count, rate] of categories) {
|
|
25
27
|
if (rate === undefined)
|
|
26
28
|
unpriced += count;
|
|
27
29
|
else {
|
|
28
30
|
priced += count;
|
|
29
|
-
usd += count * rate / 1e6;
|
|
31
|
+
usd[category] += count * rate / 1e6;
|
|
30
32
|
}
|
|
31
33
|
}
|
|
32
34
|
return { usd, priced, unpriced };
|
|
@@ -131,9 +133,10 @@ export function queryUsage(snapshot, query, now) {
|
|
|
131
133
|
}
|
|
132
134
|
}
|
|
133
135
|
const incomplete = "attemptId" in entry && incompleteAttempts.has(entry.attemptId);
|
|
136
|
+
const cost = price(entry);
|
|
134
137
|
if (ts >= yearStart && ts < yearEnd) {
|
|
135
138
|
const day = days[Math.floor((ts - yearStart) / DAY)];
|
|
136
|
-
addToBucket(day, entry, model);
|
|
139
|
+
addToBucket(day, entry, model, cost.usd);
|
|
137
140
|
if (incomplete && day.coverage === "complete")
|
|
138
141
|
day.coverage = "partial";
|
|
139
142
|
}
|
|
@@ -143,13 +146,12 @@ export function queryUsage(snapshot, query, now) {
|
|
|
143
146
|
incompleteInPeriod = true;
|
|
144
147
|
observedAccounts.add(entry.accountKey);
|
|
145
148
|
addTokens(totals, entry.tokens);
|
|
146
|
-
|
|
147
|
-
pricedApiUsd += cost.usd;
|
|
149
|
+
pricedApiUsd += totalSpend(cost.usd);
|
|
148
150
|
pricedTokens += cost.priced;
|
|
149
151
|
unpricedTokens += cost.unpriced;
|
|
150
152
|
const bucket = buckets.find(candidate => candidate.start <= iso(ts) && candidate.end > iso(ts));
|
|
151
153
|
if (bucket)
|
|
152
|
-
addToBucket(bucket, entry, model);
|
|
154
|
+
addToBucket(bucket, entry, model, cost.usd);
|
|
153
155
|
}
|
|
154
156
|
const subscriptions = snapshot.subscriptions.filter(entry => providers.has(entry.provider));
|
|
155
157
|
const overlappingSubscriptions = subscriptions.filter(entry => Date.parse(entry.from) < elapsedEnd && (!entry.to || Date.parse(entry.to) > start));
|
package/dist/usage/types.js
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
export const USAGE_PROVIDERS = ["anthropic_subscription", "openai_subscription", "xai_subscription"];
|
|
2
2
|
export const TOKEN_KEYS = ["input", "output", "cacheRead", "cacheWrite", "cacheWrite5m", "cacheWrite1h"];
|
|
3
|
+
export const SPEND_KEYS = ["input", "output", "cacheRead", "cacheWrite"];
|
|
4
|
+
export function zeroSpend() { return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; }
|
|
5
|
+
export function totalSpend(usd) { return usd.input + usd.output + usd.cacheRead + usd.cacheWrite; }
|
|
6
|
+
export function addSpend(target, usd) { for (const key of SPEND_KEYS)
|
|
7
|
+
target[key] += usd[key]; }
|
|
8
|
+
export function validateSpend(value) {
|
|
9
|
+
const input = object(value, SPEND_KEYS);
|
|
10
|
+
const result = zeroSpend();
|
|
11
|
+
for (const key of SPEND_KEYS) {
|
|
12
|
+
const amount = input[key] ?? 0;
|
|
13
|
+
if (typeof amount !== "number" || !Number.isFinite(amount) || amount < 0)
|
|
14
|
+
throw new Error(`Invalid ${key} spend`);
|
|
15
|
+
result[key] = amount;
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
3
19
|
export function zeroTokens() { return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite5m: 0, cacheWrite1h: 0 }; }
|
|
4
20
|
export function totalTokens(tokens) { return tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite; }
|
|
5
21
|
export function addTokens(target, tokens) {
|
package/package.json
CHANGED