@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 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.
@@ -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
- ctx.ui.setStatus("usage", footerText(session));
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
- ctx.ui.notify(quotaReport([]), "info");
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
- ctx.ui.notify(redact(quotaReport(results)), results.every((r) => r.ok) ? "info" : "warning");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/usage",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "description": "Token and cost reporting for pi sessions: live footer, local-history dashboard with per-project costs, opt-in provider quota",
5
5
  "keywords": [
6
6
  "pi-package",
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 used โ€” OpenAI, Anthropic and Gemini publish none for subscription quota.",
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");
@@ -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
+ }