@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 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 savings
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. Switch between day, week, month and year; history
116
- survives service restarts. Configure your monthly subscription costs to compare
117
- them with the estimated standard API token value:
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
@@ -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 subscription savings")
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 subscription savings
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
- const fullModels = [...new Map(report?.buckets.flatMap(bucket => Object.values(bucket.series).map(series => [`${series.provider}:${series.model}`, series])) ?? []).values()].sort((a, b) => `${a.provider}:${a.model}`.localeCompare(`${b.provider}:${b.model}`));
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
- return _jsxs(Box, { width: width, 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)).map((text, i) => line(text, `model-name${i}`)), line("↑↓ browse · l back · q quit", "model-help")] });
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
- lines.push(line(_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: formatTokens(totalTokens(totals)) }), " tokens Input ", formatTokens(totals.input), " Output ", formatTokens(totals.output), " Cache ", formatTokens(totals.cacheRead), " read / ", formatTokens(totals.cacheWrite), " write"] }), "tokens"));
138
- const saved = _jsxs(_Fragment, { children: [_jsx(Text, { color: costs.savingsUsd !== null && costs.savingsUsd < 0 ? "red" : "green", children: money(costs.savingsUsd) }), costs.savingsPercent === null ? "" : ` (${costs.savingsPercent.toFixed(1)}%)`] });
139
- lines.push(line(_jsxs(_Fragment, { children: [width < 68 ? "API" : "API equivalent", " ", money(costs.pricedApiUsd), costs.coverage.pricingComplete ? "" : " (partial)", " Sub ", money(costs.subscriptionUsd), costs.coverage.subscriptionComplete ? "" : " (partial)", width >= 68 ? _jsxs(_Fragment, { children: [" Savings ", saved] }) : null] }), "costs"));
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(_jsxs(_Fragment, { children: ["Savings ", saved] }), "savings"));
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)), _jsx(Text, { dimColor: true, children: models ? "Models" : "Providers" })] }), "providers"));
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
- if (warning)
146
- lines.push(line(_jsxs(Text, { color: "yellow", children: [error ? "Error" : "Partial", ": ", warning, report && report.warnings.length > 1 ? ` (+${report.warnings.length - 1})` : ""] }), "warning"));
147
- else if (report && totalTokens(report.totals) === 0)
148
- lines.push(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"));
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 chartHeight = showBoth ? Math.max(2, Math.min(8, height - lines.length - 13)) : Math.max(2, Math.min(8, height - lines.length - 4));
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: totalTokens(s.tokens) })),
157
- })), Math.max(1, Math.floor((width - 7) / 2)), chartHeight, models, models ? Math.max(2, Math.floor(width / 24)) : 3);
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 ? formatTokens(chart.max) : row === chartHeight - 1 ? "0" : "").padStart(5), " \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(2) : " " }, i))] }), `bar${row}`));
162
- lines.push(line(_jsxs(Text, { dimColor: true, children: [" └", chart.columns.map((col, i) => i % Math.max(1, Math.ceil(chart.columns.length / 8)) === 0 ? col.label.padStart(2).slice(-2) : " ").join("")] }), "axis"));
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 => totalTokens(day.tokens)));
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]" : "[daily tokens]"] }), "months"));
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(totalTokens(day.tokens), max)], children: day.coverage === "future" ? "· " : day.coverage === "untracked" ? "? " : day.coverage === "partial" ? "▧ " : "■ " }, i);
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} ${totalTokens(selected.tokens).toLocaleString("en-US")} tokens · ${selected.coverage}` : "Daily tokens", " Less \u2591\u2592\u2593\u2588 More"] }), "day"));
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} ${totalTokens(selected.tokens).toLocaleString("en-US")} tokens`, "compact-inspector"));
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 >= 76 ? "q quit · ? help · Tab period · ←→ move · t today · 1–3 · m models · g grid · l names" : width >= 38 ? "q quit · ? help · Tab ←→ t 1–3 m g l" : "q quit · ? help";
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
  }
@@ -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) {
@@ -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
- let usd = 0;
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
- const cost = price(entry);
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));
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timo972/cc-router",
3
- "version": "0.13.0-rc.3",
3
+ "version": "0.13.0-rc.4",
4
4
  "description": "Cache-aware session router for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {