@pify/usage 0.1.0 → 0.3.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 +17 -1
- package/extensions/usage.ts +34 -6
- package/package.json +1 -1
- package/src/aggregate.ts +7 -1
- package/src/format.ts +19 -11
- package/src/quota.ts +124 -0
- package/src/sessions.ts +61 -18
- package/src/types.ts +3 -0
package/README.md
CHANGED
|
@@ -22,12 +22,28 @@ History (214 local session files)
|
|
|
22
22
|
By model (all time)
|
|
23
23
|
anthropic/claude-fable-5 $12.30 · 4.1M tok
|
|
24
24
|
openai/gpt-5.5 $9.10 · 5.7M tok
|
|
25
|
+
By project (all time)
|
|
26
|
+
D--project-pify-plugins $14.80 · 6.2M tok
|
|
27
|
+
D--project-shop-api $6.60 · 3.6M tok
|
|
25
28
|
```
|
|
26
29
|
|
|
27
30
|
- **History done right** (tmustier's lessons): counts every usage-bearing entry in pi's session JSONL — assistant turns plus the tool-result/compaction usage pi 0.81+ persists; negative/NaN fields clamp to zero; days are your local calendar days; a per-file mtime cache keeps repeat scans instant.
|
|
31
|
+
- **Per-project spend** (v0.2): pi stores sessions one directory per project, so the dashboard can show where the money actually went — the top 5 projects by cost, all time.
|
|
28
32
|
- **`usage_status` tool**: the agent can check session + today totals before committing to expensive work (subagent fan-outs, large reads).
|
|
29
33
|
|
|
30
|
-
|
|
34
|
+
## `/usage quota` (v0.3)
|
|
35
|
+
|
|
36
|
+
The one command in this package that touches the network, and only when you run it:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
Quota (openrouter · sk-or-v1-395...563)
|
|
40
|
+
spent $0.32 (no credit limit on this key)
|
|
41
|
+
window day $0.32 · week $0.32 · month $0.32
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The key is read from the same `~/.pi/agent/auth.json` pi already uses (or `OPENROUTER_API_KEY`) — no second place to configure credentials — and only the provider's own masked label is ever printed. An 8-second timeout, and any failure renders as `unavailable — HTTP 401` rather than throwing.
|
|
45
|
+
|
|
46
|
+
Only OpenRouter is implemented. The other providers report opaque rate-limit windows rather than a balance, and chasing all of them costs ~18k lines of per-provider contract maintenance (see `@narumitw/pi-usage` if you need them today).
|
|
31
47
|
|
|
32
48
|
## License
|
|
33
49
|
|
package/extensions/usage.ts
CHANGED
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
* tmustier's lesson) with a per-file mtime cache. The usage_status tool
|
|
11
11
|
* lets the agent itself check consumption mid-session.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
* v0.
|
|
15
|
-
*
|
|
13
|
+
* Everything above is local: no network, no LLM tokens. The single exception
|
|
14
|
+
* is /usage quota (v0.3), which asks OpenRouter what this key has spent —
|
|
15
|
+
* opt-in per call, 8s timeout, and a failure prints as "unavailable" beside
|
|
16
|
+
* the local numbers. Other providers stay out: @narumitw/pi-usage shows the
|
|
17
|
+
* full set costs ~18k lines of per-provider contract chasing, and OpenRouter
|
|
18
|
+
* is the one that reports a real balance rather than an opaque window.
|
|
16
19
|
*/
|
|
17
20
|
import {
|
|
18
21
|
getAgentDir,
|
|
@@ -21,9 +24,11 @@ import {
|
|
|
21
24
|
} from "@earendil-works/pi-coding-agent";
|
|
22
25
|
import { Type } from "typebox";
|
|
23
26
|
import { join } from "node:path";
|
|
27
|
+
import { readFileSync } from "node:fs";
|
|
24
28
|
|
|
25
29
|
import { addRecord, aggregate, recordFromEntry, windowTotals } from "../src/aggregate.ts";
|
|
26
30
|
import { footerText, formatCost, formatTokens, historyBlock, sessionBlock } from "../src/format.ts";
|
|
31
|
+
import { fetchOpenRouterQuota, quotaBlock } from "../src/quota.ts";
|
|
27
32
|
import { scanSessions } from "../src/sessions.ts";
|
|
28
33
|
import { emptyTotals, isRecord, type UsageTotals } from "../src/types.ts";
|
|
29
34
|
|
|
@@ -86,10 +91,33 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
86
91
|
|
|
87
92
|
// ── Command & tool ───────────────────────────────────────────────────
|
|
88
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The key pi itself uses, read from the same auth.json — no second place to
|
|
96
|
+
* configure credentials, and no key is ever printed.
|
|
97
|
+
*/
|
|
98
|
+
function providerKey(provider: string): string {
|
|
99
|
+
try {
|
|
100
|
+
const auth = JSON.parse(readFileSync(join(getAgentDir(), "auth.json"), "utf8")) as Record<string, unknown>;
|
|
101
|
+
const entry = auth[provider];
|
|
102
|
+
if (isRecord(entry) && typeof entry.key === "string") return entry.key;
|
|
103
|
+
} catch {
|
|
104
|
+
// no auth.json, or unreadable — fall through to the environment
|
|
105
|
+
}
|
|
106
|
+
return process.env.OPENROUTER_API_KEY ?? "";
|
|
107
|
+
}
|
|
108
|
+
|
|
89
109
|
pi.registerCommand("usage", {
|
|
90
|
-
description: "Token and cost dashboard:
|
|
91
|
-
handler: async (
|
|
92
|
-
if (ctx.hasUI)
|
|
110
|
+
description: "Token and cost dashboard: /usage [quota]",
|
|
111
|
+
handler: async (args, ctx) => {
|
|
112
|
+
if (!ctx.hasUI) return;
|
|
113
|
+
if ((args ?? "").trim().toLowerCase() === "quota") {
|
|
114
|
+
// The one networked call in this package, and only when asked for.
|
|
115
|
+
ctx.ui.notify("Checking provider quota…", "info");
|
|
116
|
+
const result = await fetchOpenRouterQuota(providerKey("openrouter"));
|
|
117
|
+
ctx.ui.notify(quotaBlock(result), result.ok ? "info" : "warning");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
ctx.ui.notify(dashboard(ctx), "info");
|
|
93
121
|
},
|
|
94
122
|
});
|
|
95
123
|
|
package/package.json
CHANGED
package/src/aggregate.ts
CHANGED
|
@@ -32,6 +32,7 @@ export function dayKey(timestamp: number): string {
|
|
|
32
32
|
export function aggregate(records: UsageRecord[], files: number): HistoryAggregate {
|
|
33
33
|
const byDay = new Map<string, UsageTotals>();
|
|
34
34
|
const byModel = new Map<string, UsageTotals>();
|
|
35
|
+
const byProject = new Map<string, UsageTotals>();
|
|
35
36
|
const total = emptyTotals();
|
|
36
37
|
|
|
37
38
|
for (const record of records) {
|
|
@@ -40,9 +41,13 @@ export function aggregate(records: UsageRecord[], files: number): HistoryAggrega
|
|
|
40
41
|
addRecord(byDay.get(day) ?? byDay.set(day, emptyTotals()).get(day)!, record);
|
|
41
42
|
const model = `${record.provider}/${record.model}`;
|
|
42
43
|
addRecord(byModel.get(model) ?? byModel.set(model, emptyTotals()).get(model)!, record);
|
|
44
|
+
if (record.project) {
|
|
45
|
+
const p = record.project;
|
|
46
|
+
addRecord(byProject.get(p) ?? byProject.set(p, emptyTotals()).get(p)!, record);
|
|
47
|
+
}
|
|
43
48
|
}
|
|
44
49
|
|
|
45
|
-
return { byDay, byModel, total, files };
|
|
50
|
+
return { byDay, byModel, byProject, total, files };
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
/** Sum totals for days within the trailing window (inclusive of today). */
|
|
@@ -91,6 +96,7 @@ export function recordFromEntry(entry: unknown): UsageRecord | null {
|
|
|
91
96
|
timestamp: Number.isFinite(ts) ? ts : 0,
|
|
92
97
|
model: typeof message.model === "string" ? message.model : "unknown",
|
|
93
98
|
provider: typeof message.provider === "string" ? message.provider : "unknown",
|
|
99
|
+
project: "",
|
|
94
100
|
input: finite(usage.input),
|
|
95
101
|
output: finite(usage.output),
|
|
96
102
|
cacheRead: finite(usage.cacheRead),
|
package/src/format.ts
CHANGED
|
@@ -43,16 +43,24 @@ export function historyBlock(history: HistoryAggregate, now: number): string {
|
|
|
43
43
|
` 30 days ${formatCost(month.cost)} · ${formatTokens(month.totalTokens)} tok`,
|
|
44
44
|
];
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
.slice(0, 6);
|
|
49
|
-
if (models.length > 0) {
|
|
50
|
-
lines.push("By model (all time)");
|
|
51
|
-
const width = models.reduce((m, [name]) => Math.max(m, Math.min(name.length, 40)), 0);
|
|
52
|
-
for (const [name, totals] of models) {
|
|
53
|
-
const label = name.length > 40 ? `${name.slice(0, 39)}…` : name;
|
|
54
|
-
lines.push(` ${label.padEnd(width)} ${formatCost(totals.cost)} · ${formatTokens(totals.totalTokens)} tok`);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
46
|
+
pushBreakdown(lines, "By model (all time)", history.byModel, 6);
|
|
47
|
+
pushBreakdown(lines, "By project (all time)", history.byProject, 5);
|
|
57
48
|
return lines.join("\n");
|
|
58
49
|
}
|
|
50
|
+
|
|
51
|
+
/** One "name $cost · N tok" table, biggest spend first. */
|
|
52
|
+
function pushBreakdown(
|
|
53
|
+
lines: string[],
|
|
54
|
+
heading: string,
|
|
55
|
+
totals: Map<string, UsageTotals>,
|
|
56
|
+
limit: number,
|
|
57
|
+
): void {
|
|
58
|
+
const rows = [...totals.entries()].sort((a, b) => b[1].cost - a[1].cost).slice(0, limit);
|
|
59
|
+
if (rows.length === 0) return;
|
|
60
|
+
lines.push(heading);
|
|
61
|
+
const width = rows.reduce((m, [name]) => Math.max(m, Math.min(name.length, 40)), 0);
|
|
62
|
+
for (const [name, t] of rows) {
|
|
63
|
+
const label = name.length > 40 ? `${name.slice(0, 39)}…` : name;
|
|
64
|
+
lines.push(` ${label.padEnd(width)} ${formatCost(t.cost)} · ${formatTokens(t.totalTokens)} tok`);
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/quota.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider quota (v0.3). Everything else in this package is computed from
|
|
3
|
+
* local files — this is the one place that talks to a network, so it is
|
|
4
|
+
* opt-in per call, short-timeout, and never blocks the dashboard: a provider
|
|
5
|
+
* that is slow or down shows as unavailable next to the local numbers.
|
|
6
|
+
*
|
|
7
|
+
* Only OpenRouter is implemented. @narumitw/pi-usage shows what the full set
|
|
8
|
+
* costs — roughly 18k lines of per-provider contract chasing — and OpenRouter
|
|
9
|
+
* is the one endpoint that reports a real balance rather than an opaque
|
|
10
|
+
* rate-limit window.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { finite, isRecord } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
export interface QuotaInfo {
|
|
16
|
+
provider: string;
|
|
17
|
+
/** Spend on this key, in USD, as the provider reports it. */
|
|
18
|
+
used: number | null;
|
|
19
|
+
/** Hard credit limit, when the key has one. */
|
|
20
|
+
limit: number | null;
|
|
21
|
+
remaining: number | null;
|
|
22
|
+
/** Rolling-window spend, when reported. */
|
|
23
|
+
daily: number | null;
|
|
24
|
+
weekly: number | null;
|
|
25
|
+
monthly: number | null;
|
|
26
|
+
label: string | null;
|
|
27
|
+
freeTier: boolean | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type QuotaResult =
|
|
31
|
+
| { ok: true; quota: QuotaInfo }
|
|
32
|
+
| { ok: false; provider: string; reason: string };
|
|
33
|
+
|
|
34
|
+
export const QUOTA_TIMEOUT_MS = 8000;
|
|
35
|
+
|
|
36
|
+
function num(value: unknown): number | null {
|
|
37
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Shape the /api/v1/key payload. OpenRouter reports `limit: null` for keys
|
|
42
|
+
* with no cap, so "no limit" and "limit of zero" must not collapse together.
|
|
43
|
+
*/
|
|
44
|
+
export function parseOpenRouterKey(payload: unknown): QuotaInfo | null {
|
|
45
|
+
if (!isRecord(payload)) return null;
|
|
46
|
+
// The endpoint wraps its fields in `data`; a flat body is accepted too, but
|
|
47
|
+
// a `data` that is present and not an object means the shape changed.
|
|
48
|
+
if ("data" in payload && !isRecord(payload.data)) return null;
|
|
49
|
+
const data = isRecord(payload.data) ? payload.data : payload;
|
|
50
|
+
|
|
51
|
+
const used = num(data.usage);
|
|
52
|
+
const limit = num(data.limit);
|
|
53
|
+
const remaining = num(data.limit_remaining) ?? (limit !== null && used !== null ? limit - used : null);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
provider: "openrouter",
|
|
57
|
+
used: used === null ? null : finite(used),
|
|
58
|
+
limit,
|
|
59
|
+
remaining,
|
|
60
|
+
daily: num(data.usage_daily),
|
|
61
|
+
weekly: num(data.usage_weekly),
|
|
62
|
+
monthly: num(data.usage_monthly),
|
|
63
|
+
label: typeof data.label === "string" ? data.label : null,
|
|
64
|
+
freeTier: typeof data.is_free_tier === "boolean" ? data.is_free_tier : null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type Fetcher = (url: string, init: { headers: Record<string, string>; signal: AbortSignal }) => Promise<{
|
|
69
|
+
ok: boolean;
|
|
70
|
+
status: number;
|
|
71
|
+
json(): Promise<unknown>;
|
|
72
|
+
}>;
|
|
73
|
+
|
|
74
|
+
/** Fetch the OpenRouter key status. Never throws — failure is a result. */
|
|
75
|
+
export async function fetchOpenRouterQuota(
|
|
76
|
+
apiKey: string,
|
|
77
|
+
fetcher: Fetcher = globalThis.fetch as unknown as Fetcher,
|
|
78
|
+
timeoutMs = QUOTA_TIMEOUT_MS,
|
|
79
|
+
): Promise<QuotaResult> {
|
|
80
|
+
if (!apiKey.trim()) return { ok: false, provider: "openrouter", reason: "no API key configured" };
|
|
81
|
+
try {
|
|
82
|
+
const response = await fetcher("https://openrouter.ai/api/v1/key", {
|
|
83
|
+
headers: { Authorization: `Bearer ${apiKey.trim()}` },
|
|
84
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
85
|
+
});
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
return { ok: false, provider: "openrouter", reason: `HTTP ${response.status}` };
|
|
88
|
+
}
|
|
89
|
+
const quota = parseOpenRouterKey(await response.json());
|
|
90
|
+
if (!quota) return { ok: false, provider: "openrouter", reason: "unexpected response shape" };
|
|
91
|
+
return { ok: true, quota };
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
provider: "openrouter",
|
|
96
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function money(value: number | null): string {
|
|
102
|
+
if (value === null) return "—";
|
|
103
|
+
if (value === 0) return "$0";
|
|
104
|
+
if (Math.abs(value) < 0.01) return "<$0.01";
|
|
105
|
+
return `$${value.toFixed(2)}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function quotaBlock(result: QuotaResult): string {
|
|
109
|
+
if (!result.ok) {
|
|
110
|
+
return `Quota (${result.provider})\n unavailable — ${result.reason}`;
|
|
111
|
+
}
|
|
112
|
+
const q = result.quota;
|
|
113
|
+
const lines = [`Quota (${q.provider}${q.label ? ` · ${q.label}` : ""})`];
|
|
114
|
+
lines.push(
|
|
115
|
+
q.limit === null
|
|
116
|
+
? ` spent ${money(q.used)} (no credit limit on this key)`
|
|
117
|
+
: ` spent ${money(q.used)} of ${money(q.limit)} · ${money(q.remaining)} left`,
|
|
118
|
+
);
|
|
119
|
+
if (q.daily !== null || q.weekly !== null || q.monthly !== null) {
|
|
120
|
+
lines.push(` window day ${money(q.daily)} · week ${money(q.weekly)} · month ${money(q.monthly)}`);
|
|
121
|
+
}
|
|
122
|
+
if (q.freeTier) lines.push(" tier free");
|
|
123
|
+
return lines.join("\n");
|
|
124
|
+
}
|
package/src/sessions.ts
CHANGED
|
@@ -44,27 +44,70 @@ export interface ScanResult {
|
|
|
44
44
|
files: number;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
const MAX_LABEL = 34;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Readable name for a project directory. pi encodes the project path into the
|
|
51
|
+
* directory name (--D--project-pify-plugins--); the tail is the recognizable
|
|
52
|
+
* part, so long names keep their end.
|
|
53
|
+
*/
|
|
54
|
+
export function projectLabel(dirName: string): string {
|
|
55
|
+
const stripped = dirName.replace(/^-+|-+$/g, "");
|
|
56
|
+
if (!stripped) return "unknown";
|
|
57
|
+
return stripped.length > MAX_LABEL ? `…${stripped.slice(-(MAX_LABEL - 1))}` : stripped;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Session files grouped by the project directory they sit under. */
|
|
61
|
+
function groupByProject(sessionsDir: string): Array<{ project: string; files: string[] }> {
|
|
62
|
+
let names: string[];
|
|
63
|
+
try {
|
|
64
|
+
names = readdirSync(sessionsDir);
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
const groups: Array<{ project: string; files: string[] }> = [];
|
|
69
|
+
const loose: string[] = [];
|
|
70
|
+
for (const name of names) {
|
|
71
|
+
const full = join(sessionsDir, name);
|
|
51
72
|
try {
|
|
52
|
-
const stat = statSync(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
const fileRecords: UsageRecord[] = [];
|
|
59
|
-
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
60
|
-
const record = recordFromLine(line);
|
|
61
|
-
if (record) fileRecords.push(record);
|
|
73
|
+
const stat = statSync(full);
|
|
74
|
+
if (stat.isDirectory()) {
|
|
75
|
+
groups.push({ project: projectLabel(name), files: listJsonlFiles(full, 1) });
|
|
76
|
+
} else if (name.endsWith(".jsonl") && stat.size <= MAX_FILE_BYTES) {
|
|
77
|
+
loose.push(full);
|
|
62
78
|
}
|
|
63
|
-
cache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, records: fileRecords });
|
|
64
|
-
records.push(...fileRecords);
|
|
65
79
|
} catch {
|
|
66
|
-
//
|
|
80
|
+
// race/permission — skip
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (loose.length > 0) groups.push({ project: "", files: loose });
|
|
84
|
+
return groups;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function scanSessions(sessionsDir: string): ScanResult {
|
|
88
|
+
const records: UsageRecord[] = [];
|
|
89
|
+
let fileCount = 0;
|
|
90
|
+
for (const { project, files } of groupByProject(sessionsDir)) {
|
|
91
|
+
fileCount += files.length;
|
|
92
|
+
for (const file of files) {
|
|
93
|
+
try {
|
|
94
|
+
const stat = statSync(file);
|
|
95
|
+
const cached = cache.get(file);
|
|
96
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
97
|
+
records.push(...cached.records);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const fileRecords: UsageRecord[] = [];
|
|
101
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
102
|
+
const record = recordFromLine(line);
|
|
103
|
+
if (record) fileRecords.push({ ...record, project });
|
|
104
|
+
}
|
|
105
|
+
cache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, records: fileRecords });
|
|
106
|
+
records.push(...fileRecords);
|
|
107
|
+
} catch {
|
|
108
|
+
// unreadable — skip
|
|
109
|
+
}
|
|
67
110
|
}
|
|
68
111
|
}
|
|
69
|
-
return { records, files:
|
|
112
|
+
return { records, files: fileCount };
|
|
70
113
|
}
|
package/src/types.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface UsageRecord {
|
|
|
23
23
|
timestamp: number;
|
|
24
24
|
model: string;
|
|
25
25
|
provider: string;
|
|
26
|
+
/** Project the session belongs to; "" when unknown (live events). */
|
|
27
|
+
project: string;
|
|
26
28
|
input: number;
|
|
27
29
|
output: number;
|
|
28
30
|
cacheRead: number;
|
|
@@ -34,6 +36,7 @@ export interface UsageRecord {
|
|
|
34
36
|
export interface HistoryAggregate {
|
|
35
37
|
byDay: Map<string, UsageTotals>;
|
|
36
38
|
byModel: Map<string, UsageTotals>;
|
|
39
|
+
byProject: Map<string, UsageTotals>;
|
|
37
40
|
total: UsageTotals;
|
|
38
41
|
files: number;
|
|
39
42
|
}
|