@pify/usage 0.5.2 โ 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/extensions/usage.ts +55 -5
- package/package.json +1 -1
- package/src/format.ts +19 -0
- package/src/quota.ts +1 -1
- package/src/ratelimit.ts +133 -0
package/README.md
CHANGED
|
@@ -11,11 +11,13 @@ Spend is invisible until the invoice arrives, and by then you cannot tell which
|
|
|
11
11
|
## Live footer
|
|
12
12
|
|
|
13
13
|
```
|
|
14
|
-
๐ 12.3k tok ยท $0.45
|
|
14
|
+
๐ 12.3k tok ยท $0.45 ยท ctx โฐโฐโฐโฑโฑโฑ 34%
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
Folded from each message's `usage.cost`, which pi already computes. It survives `/reload` by replaying the session branch rather than keeping a running total in memory.
|
|
18
18
|
|
|
19
|
+
The gauge on the right is how full the context window is โ the third mid-session question, next to tokens and cost. It was computed for the `/usage` dashboard but shown only there; now it is live. It appears once there is a window to measure against (so not under `-p`) and turns to `โ ` past 90%, the point where "how full" stops being trivia and becomes a decision. For the breakdown of what filled it, run `/context`.
|
|
20
|
+
|
|
19
21
|
## `/usage`
|
|
20
22
|
|
|
21
23
|
```
|
|
@@ -40,7 +42,7 @@ History counts every usage-bearing entry in pi's session JSONL โ assistant tur
|
|
|
40
42
|
|
|
41
43
|
Per-project totals come for free: pi stores sessions one directory per project, so the dashboard can show where the money actually went.
|
|
42
44
|
|
|
43
|
-
## `/usage context`
|
|
45
|
+
## `/context` (or `/usage context`)
|
|
44
46
|
|
|
45
47
|
```
|
|
46
48
|
Context window: 22.6k of 200.0k used (11%)
|
|
@@ -73,6 +75,8 @@ Quota (DeepSeek ยท granted 10 ยท topped up 100)
|
|
|
73
75
|
|
|
74
76
|
**Documented endpoints only.** OpenRouter's `/api/v1/key` and DeepSeek's `/user/balance` are published APIs that report a real balance. The subscription-quota endpoints available for some other providers are undocumented private APIs reverse-engineered from vendor CLIs โ they break without notice and were never offered to third parties, so this package does not call them. A provider you have not configured is simply not shown; that is not a failure.
|
|
75
77
|
|
|
78
|
+
**Rate limits, for free.** OpenAI, Anthropic and Gemini publish no balance API, but every completion response carries rate-limit headers, and pi hands them to extensions through `after_provider_response` โ before the stream is read, at no cost when unsubscribed. So `/usage quota` also shows what the current provider reported on its last call โ requests and tokens left in the window, and for an Anthropic OAuth subscription the unified-window percentage โ captured passively from calls the session already made, with no extra request and no credential to handle.
|
|
79
|
+
|
|
76
80
|
**A credentialed request is pinned down**, because it carries your provider key:
|
|
77
81
|
|
|
78
82
|
- HTTPS only, and the host must be on that provider's allowlist.
|
package/extensions/usage.ts
CHANGED
|
@@ -29,10 +29,11 @@ import { readFileSync } from "node:fs";
|
|
|
29
29
|
|
|
30
30
|
import { addRecord, aggregate, recordFromEntry, windowTotals } from "../src/aggregate.ts";
|
|
31
31
|
import { buildBreakdown, formatBreakdown } from "../src/context.ts";
|
|
32
|
-
import { footerText, formatCost, formatTokens, historyBlock, sessionBlock } from "../src/format.ts";
|
|
32
|
+
import { contextGauge, footerText, formatCost, formatTokens, historyBlock, sessionBlock } from "../src/format.ts";
|
|
33
33
|
import { QUOTA_PROVIDERS, fetchQuota, quotaReport, type QuotaResult } from "../src/quota.ts";
|
|
34
34
|
import { redact } from "../src/redact.ts";
|
|
35
35
|
import { scanSessions } from "../src/sessions.ts";
|
|
36
|
+
import { parseRateLimit, formatRateLimit, type RateLimitSnapshot } from "../src/ratelimit.ts";
|
|
36
37
|
import { emptyTotals, isRecord, type UsageTotals } from "../src/types.ts";
|
|
37
38
|
|
|
38
39
|
type UiContext = ExtensionContext;
|
|
@@ -41,10 +42,33 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
41
42
|
let session: UsageTotals = emptyTotals();
|
|
42
43
|
/** input+cacheRead of the most recent assistant message โ context size. */
|
|
43
44
|
let lastPromptTokens = 0;
|
|
45
|
+
/**
|
|
46
|
+
* The latest rate-limit headers seen per provider โ passive quota, captured
|
|
47
|
+
* free from responses the session already made, no extra network call.
|
|
48
|
+
*/
|
|
49
|
+
const rateLimits = new Map<string, RateLimitSnapshot>();
|
|
50
|
+
|
|
51
|
+
// after_provider_response carries the status and full headers of every
|
|
52
|
+
// provider call, before the stream is read; gated on hasHandlers, so
|
|
53
|
+
// subscribing costs nothing per request. The event does not name the
|
|
54
|
+
// provider, so it is read from the model in context.
|
|
55
|
+
pi.on("after_provider_response", async (event, ctx) => {
|
|
56
|
+
const provider = (ctx as { model?: { provider?: string } }).model?.provider;
|
|
57
|
+
if (!provider) return;
|
|
58
|
+
const evt = event as unknown as { headers?: Record<string, string> };
|
|
59
|
+
const snap = parseRateLimit(provider, evt.headers ?? {}, Date.now());
|
|
60
|
+
if (snap) rateLimits.set(provider, snap);
|
|
61
|
+
});
|
|
44
62
|
|
|
45
63
|
function updateFooter(ctx: UiContext): void {
|
|
46
64
|
if (!ctx.hasUI) return;
|
|
47
|
-
|
|
65
|
+
const base = footerText(session);
|
|
66
|
+
if (!base) {
|
|
67
|
+
ctx.ui.setStatus("usage", undefined);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const gauge = contextGauge(contextPct(ctx));
|
|
71
|
+
ctx.ui.setStatus("usage", gauge ? `${base} ยท ${gauge}` : base);
|
|
48
72
|
}
|
|
49
73
|
|
|
50
74
|
function contextPct(ctx: UiContext): number | null {
|
|
@@ -169,9 +193,15 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
169
193
|
}
|
|
170
194
|
|
|
171
195
|
pi.registerCommand("usage", {
|
|
172
|
-
description: "Token and cost dashboard: /usage [quota]",
|
|
196
|
+
description: "Token and cost dashboard: /usage [context | quota]",
|
|
173
197
|
handler: async (args, ctx) => {
|
|
174
198
|
if (!ctx.hasUI) return;
|
|
199
|
+
if ((args ?? "").trim().toLowerCase() === "context") {
|
|
200
|
+
// Documented since the breakdown landed, but never wired โ the handler
|
|
201
|
+
// only knew "quota", so `/usage context` quietly showed the dashboard.
|
|
202
|
+
ctx.ui.notify(contextBreakdown(ctx), "info");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
175
205
|
if ((args ?? "").trim().toLowerCase() === "quota") {
|
|
176
206
|
// The one networked path in this package, and only when asked for.
|
|
177
207
|
// Providers with no key are skipped entirely rather than reported as
|
|
@@ -182,7 +212,13 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
182
212
|
if (key) configured.push({ provider, key });
|
|
183
213
|
}
|
|
184
214
|
if (configured.length === 0) {
|
|
185
|
-
|
|
215
|
+
const passive = [...rateLimits.values()]
|
|
216
|
+
.map((s) => formatRateLimit(s))
|
|
217
|
+
.filter((line): line is string => line !== null);
|
|
218
|
+
ctx.ui.notify(
|
|
219
|
+
passive.length > 0 ? redact(`${passive.join("\n")}\n\n${quotaReport([])}`) : quotaReport([]),
|
|
220
|
+
"info",
|
|
221
|
+
);
|
|
186
222
|
return;
|
|
187
223
|
}
|
|
188
224
|
ctx.ui.notify(`Checking quota for ${configured.map((c) => c.provider.displayName).join(", ")}โฆ`, "info");
|
|
@@ -190,13 +226,27 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
190
226
|
for (const { provider, key } of configured) {
|
|
191
227
|
results.push(await fetchQuota(provider, key));
|
|
192
228
|
}
|
|
193
|
-
|
|
229
|
+
const passive = [...rateLimits.values()]
|
|
230
|
+
.map((s) => formatRateLimit(s))
|
|
231
|
+
.filter((line): line is string => line !== null);
|
|
232
|
+
const passiveBlock = passive.length > 0 ? `${passive.join("\n")}\n\n` : "";
|
|
233
|
+
ctx.ui.notify(redact(passiveBlock + quotaReport(results)), results.every((r) => r.ok) ? "info" : "warning");
|
|
194
234
|
return;
|
|
195
235
|
}
|
|
196
236
|
ctx.ui.notify(dashboard(ctx), "info");
|
|
197
237
|
},
|
|
198
238
|
});
|
|
199
239
|
|
|
240
|
+
pi.registerCommand("context", {
|
|
241
|
+
description: "Where the context window went: /context",
|
|
242
|
+
handler: async (_args, ctx) => {
|
|
243
|
+
// The live footer gauge says how full the window is; this says spent on
|
|
244
|
+
// WHAT โ the breakdown was built for the /usage dashboard's one-line note
|
|
245
|
+
// but the full bar view had no command to reach it until now.
|
|
246
|
+
if (ctx.hasUI) ctx.ui.notify(contextBreakdown(ctx), "info");
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
200
250
|
pi.registerTool({
|
|
201
251
|
name: "usage_status",
|
|
202
252
|
label: "Usage status",
|
package/package.json
CHANGED
package/src/format.ts
CHANGED
|
@@ -19,6 +19,25 @@ export function footerText(session: UsageTotals): string | undefined {
|
|
|
19
19
|
return `๐ ${formatTokens(session.totalTokens)} tok ยท ${formatCost(session.cost)}`;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
const GAUGE_CELLS = 6;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A compact live context gauge for the footer: a filled/empty bar and the
|
|
26
|
+
* percentage of the window in use. The footer already carries tokens and cost;
|
|
27
|
+
* this answers the third question you have mid-session โ how close am I to the
|
|
28
|
+
* wall โ which was computed for the /usage dashboard but never shown live. It
|
|
29
|
+
* warns once the window is nearly full, because that is when the number stops
|
|
30
|
+
* being trivia and starts being a decision. Empty when there is no window to
|
|
31
|
+
* measure against (e.g. under `-p`, or before the first response).
|
|
32
|
+
*/
|
|
33
|
+
export function contextGauge(pct: number | null): string {
|
|
34
|
+
if (pct === null || !Number.isFinite(pct)) return "";
|
|
35
|
+
const clamped = Math.max(0, Math.min(100, pct));
|
|
36
|
+
const filled = Math.round((clamped / 100) * GAUGE_CELLS);
|
|
37
|
+
const bar = "โฐ".repeat(filled) + "โฑ".repeat(GAUGE_CELLS - filled);
|
|
38
|
+
return `${clamped >= 90 ? "โ " : ""}ctx ${bar} ${Math.round(clamped)}%`;
|
|
39
|
+
}
|
|
40
|
+
|
|
22
41
|
export function sessionBlock(session: UsageTotals, contextPct: number | null): string {
|
|
23
42
|
const lines = [
|
|
24
43
|
"Session",
|
package/src/quota.ts
CHANGED
|
@@ -221,7 +221,7 @@ export function quotaReport(results: QuotaResult[]): string {
|
|
|
221
221
|
return [
|
|
222
222
|
"No provider with a quota endpoint is configured.",
|
|
223
223
|
`Supported: ${QUOTA_PROVIDERS.map((p) => p.displayName).join(", ")}.`,
|
|
224
|
-
"Only documented endpoints are
|
|
224
|
+
"Only documented balance endpoints are polled. OpenAI, Anthropic and Gemini publish no balance API, but their rate-limit headers are read passively from ordinary responses โ shown above when the session has made a call.",
|
|
225
225
|
].join("\n");
|
|
226
226
|
}
|
|
227
227
|
return results.map(quotaBlock).join("\n\n");
|
package/src/ratelimit.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate-limit quota from the headers the provider already sent.
|
|
3
|
+
*
|
|
4
|
+
* The polling path in quota.ts asks OpenRouter and DeepSeek what a key has
|
|
5
|
+
* spent, over an extra credentialed GET, and tells the user OpenAI/Anthropic/
|
|
6
|
+
* Gemini "publish none". That last part is only true of the *balance* APIs.
|
|
7
|
+
* Every one of them returns rate-limit headers on the ordinary completion
|
|
8
|
+
* response, and pi hands extensions the full header set of every provider call
|
|
9
|
+
* through `after_provider_response` โ status + headers, emitted before the
|
|
10
|
+
* stream is read, free when nobody subscribes. So this is per-request quota
|
|
11
|
+
* with zero extra network calls and zero credentials to handle.
|
|
12
|
+
*
|
|
13
|
+
* Pure: the extension captures the latest snapshot per provider and this turns
|
|
14
|
+
* one header bag into it. Unknown providers and empty bags return null, so a
|
|
15
|
+
* response that carries nothing simply leaves the last snapshot untouched.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface RateLimitSnapshot {
|
|
19
|
+
provider: string;
|
|
20
|
+
/** Requests left in the current window, when the provider reports it. */
|
|
21
|
+
requestsRemaining: number | null;
|
|
22
|
+
/** Tokens left in the current window, when reported. */
|
|
23
|
+
tokensRemaining: number | null;
|
|
24
|
+
/** When the window resets โ provider's own string (seconds, ISO, or duration). */
|
|
25
|
+
resets: string | null;
|
|
26
|
+
/** A unified subscription-window fraction 0..1 remaining, Anthropic OAuth only. */
|
|
27
|
+
unifiedRemaining: number | null;
|
|
28
|
+
/** Which header family this came from, for the report line. */
|
|
29
|
+
source: "anthropic" | "openai" | "openrouter";
|
|
30
|
+
capturedAtMs: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function intOf(headers: Record<string, string>, key: string): number | null {
|
|
34
|
+
const raw = headers[key] ?? headers[key.toLowerCase()];
|
|
35
|
+
if (raw === undefined) return null;
|
|
36
|
+
const n = Number.parseInt(String(raw), 10);
|
|
37
|
+
return Number.isFinite(n) ? n : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function strOf(headers: Record<string, string>, key: string): string | null {
|
|
41
|
+
const raw = headers[key] ?? headers[key.toLowerCase()];
|
|
42
|
+
return raw === undefined || raw === "" ? null : String(raw);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse a header bag for a given provider id. `nowMs` is passed in rather than
|
|
47
|
+
* read from the clock, so this stays pure and testable.
|
|
48
|
+
*/
|
|
49
|
+
export function parseRateLimit(
|
|
50
|
+
provider: string,
|
|
51
|
+
headers: Record<string, string>,
|
|
52
|
+
nowMs: number,
|
|
53
|
+
): RateLimitSnapshot | null {
|
|
54
|
+
// Header keys can arrive in any case; normalise once so lookups are stable.
|
|
55
|
+
const h: Record<string, string> = {};
|
|
56
|
+
for (const [k, v] of Object.entries(headers ?? {})) h[k.toLowerCase()] = v;
|
|
57
|
+
|
|
58
|
+
const family = provider.includes("anthropic")
|
|
59
|
+
? "anthropic"
|
|
60
|
+
: provider.includes("openrouter")
|
|
61
|
+
? "openrouter"
|
|
62
|
+
: provider.includes("openai") || provider.includes("azure")
|
|
63
|
+
? "openai"
|
|
64
|
+
: null;
|
|
65
|
+
if (!family) return null;
|
|
66
|
+
|
|
67
|
+
let snap: RateLimitSnapshot | null = null;
|
|
68
|
+
|
|
69
|
+
if (family === "anthropic") {
|
|
70
|
+
const reqs = intOf(h, "anthropic-ratelimit-requests-remaining");
|
|
71
|
+
const toks = intOf(h, "anthropic-ratelimit-tokens-remaining");
|
|
72
|
+
// The OAuth Pro/Max unified subscription window, reported as a percentage.
|
|
73
|
+
const unifiedRaw = strOf(h, "anthropic-ratelimit-unified-status")
|
|
74
|
+
? intOf(h, "anthropic-ratelimit-unified-remaining")
|
|
75
|
+
: intOf(h, "anthropic-ratelimit-unified-remaining");
|
|
76
|
+
if (reqs !== null || toks !== null || unifiedRaw !== null) {
|
|
77
|
+
snap = {
|
|
78
|
+
provider,
|
|
79
|
+
requestsRemaining: reqs,
|
|
80
|
+
tokensRemaining: toks,
|
|
81
|
+
resets:
|
|
82
|
+
strOf(h, "anthropic-ratelimit-unified-reset") ??
|
|
83
|
+
strOf(h, "anthropic-ratelimit-tokens-reset") ??
|
|
84
|
+
strOf(h, "anthropic-ratelimit-requests-reset"),
|
|
85
|
+
unifiedRemaining: unifiedRaw === null ? null : Math.max(0, Math.min(1, unifiedRaw / 100)),
|
|
86
|
+
source: "anthropic",
|
|
87
|
+
capturedAtMs: nowMs,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
} else if (family === "openai") {
|
|
91
|
+
const reqs = intOf(h, "x-ratelimit-remaining-requests");
|
|
92
|
+
const toks = intOf(h, "x-ratelimit-remaining-tokens");
|
|
93
|
+
if (reqs !== null || toks !== null) {
|
|
94
|
+
snap = {
|
|
95
|
+
provider,
|
|
96
|
+
requestsRemaining: reqs,
|
|
97
|
+
tokensRemaining: toks,
|
|
98
|
+
resets: strOf(h, "x-ratelimit-reset-tokens") ?? strOf(h, "x-ratelimit-reset-requests"),
|
|
99
|
+
unifiedRemaining: null,
|
|
100
|
+
source: "openai",
|
|
101
|
+
capturedAtMs: nowMs,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
// openrouter: a single credit window on the completion response.
|
|
106
|
+
const remaining = intOf(h, "x-ratelimit-remaining");
|
|
107
|
+
if (remaining !== null) {
|
|
108
|
+
snap = {
|
|
109
|
+
provider,
|
|
110
|
+
requestsRemaining: remaining,
|
|
111
|
+
tokensRemaining: null,
|
|
112
|
+
resets: strOf(h, "x-ratelimit-reset"),
|
|
113
|
+
unifiedRemaining: null,
|
|
114
|
+
source: "openrouter",
|
|
115
|
+
capturedAtMs: nowMs,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return snap;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** One human line for the /usage quota report; null when nothing was captured. */
|
|
124
|
+
export function formatRateLimit(snap: RateLimitSnapshot | null): string | null {
|
|
125
|
+
if (!snap) return null;
|
|
126
|
+
const parts: string[] = [];
|
|
127
|
+
if (snap.unifiedRemaining !== null) parts.push(`${Math.round(snap.unifiedRemaining * 100)}% of subscription window left`);
|
|
128
|
+
if (snap.requestsRemaining !== null) parts.push(`${snap.requestsRemaining} requests left`);
|
|
129
|
+
if (snap.tokensRemaining !== null) parts.push(`${snap.tokensRemaining} tokens left`);
|
|
130
|
+
if (parts.length === 0) return null;
|
|
131
|
+
const when = snap.resets ? `, resets ${snap.resets}` : "";
|
|
132
|
+
return `${snap.provider}: ${parts.join(", ")}${when} (from response headers)`;
|
|
133
|
+
}
|