@pify/usage 0.8.0 → 0.9.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
@@ -40,7 +40,7 @@ By project (all time)
40
40
  D--project-shop-api $6.60 · 3.6M tok
41
41
  ```
42
42
 
43
- History counts every usage-bearing entry in pi's session JSONL — assistant turns plus the tool-result and compaction usage pi persists — so a session's total matches what you were billed rather than what the visible messages add up to. Negative and NaN fields clamp to zero, days are your **local** calendar days, and a per-file mtime cache keeps repeat scans instant.
43
+ History counts every usage-bearing entry in pi's session JSONL — assistant turns plus the tool-result usage pi persists, and the summariser's own call, which pi records on the compaction and branch-summary entries themselves rather than on a message. Those summary calls are among the most expensive in a session, so counting them is what makes a session's total match what you were billed rather than what the visible messages add up to; they have no model of their own, so they group under a `compaction` / `branch_summary` label in the by-model view. The live footer and session totals fold the same summariser usage in as it happens (on compaction and tree navigation), so the number does not jump only after a reload. Negative and NaN fields clamp to zero, days are your **local** calendar days, and a per-file mtime cache keeps repeat scans instant.
44
44
 
45
45
  Per-project totals come for free: pi stores sessions one directory per project, so the dashboard can show where the money actually went.
46
46
 
@@ -60,7 +60,11 @@ Context window: 22.6k of 200.0k used (11%)
60
60
 
61
61
  Computed entirely from what pi already holds: the assembled system prompt, the files and skills embedded in it, the enabled tool definitions, and the entries that would be sent. No network, no model call.
62
62
 
63
- Context files and skills are counted only when their text is genuinely embedded in the prompt, and the system-prompt row is the remainder after subtracting them, so the rows sum to the whole instead of double-counting. When the provider reports more than can be attributed, the difference is shown as **Other** rather than quietly dropped.
63
+ Context files and skills are counted only when their text is genuinely embedded in the prompt, and the system-prompt row is the remainder after subtracting them, so the rows sum to the whole instead of double-counting. When the provider reports more than can be attributed, the difference is shown as **Other** rather than quietly dropped. Image blocks (a `read` of a screenshot, a pasted image) are charged at pi's flat 4,800 chars each, not at the size of their base64 bytes, so one screenshot no longer reads as hundreds of thousands of tokens.
64
+
65
+ The "used" figure and the footer gauge are reconciled across three signals — pi's own `getContextUsage()` reading, the provider's last-request report, and the local content estimate — so a backend that reports a wrong number (a cumulative or cache-inflated total, or an implausibly small one) can't throw the gauge off: the provider's report is used when it agrees with pi's percent×window, pi's reading is trusted when they diverge beyond tolerance, and the figure is never allowed below the tokens visibly in context, nor above the window. The provider figure uses pi's own definition of context size — `totalTokens`, or `input + output + cacheRead + cacheWrite` — so it agrees with pi's footer rather than sitting low by the cache-write and output of each turn. (The reconciliation rule is from minuque/pi-cc-extensions.)
66
+
67
+ Right after `/compact`, pi reports the context as unknown until the next response, and the gauge follows: the pre-compaction figure is dropped so `/context` shows the fresh local estimate instead of the old near-full number, and the gauge reappears once the next reply lands.
64
68
 
65
69
  Reasoning gets its own **Thinking** row rather than hiding inside Conversation — on keep-thinking models it is a large, otherwise-invisible share (the opaque signature bytes are never counted or stored). And when pi's auto-compaction is on, the tokens it holds back appear as a **Compaction reserve** row and are subtracted from **Free space**, so the headroom shown is what you can actually use before compaction fires.
66
70
 
@@ -79,7 +83,7 @@ Quota (DeepSeek · granted 10 · topped up 100)
79
83
 
80
84
  **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.
81
85
 
82
- **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.
86
+ **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 (Pro/Max) subscription the two unified windows it actually reports: the fraction of the 5-hour and 7-day windows still free (from the `-5h-utilization` / `-7d-utilization` headers, which state the fraction *used*), which of the two is currently binding, an `allowed_warning` / `rejected` status when the window is under pressure, and the reset time. All captured passively from calls the session already made, with no extra request and no credential to handle.
83
87
 
84
88
  **A credentialed request is pinned down**, because it carries your provider key:
85
89
 
@@ -95,7 +99,7 @@ Quota (DeepSeek · granted 10 · topped up 100)
95
99
 
96
100
  ### `usage_status`
97
101
 
98
- No parameters. Returns session and today's totals, so the agent can check the cost before committing to expensive work — a wide subagent fan-out, a large read — instead of finding out afterwards.
102
+ No parameters. Returns session and today's totals plus how full the context window is (`~N% of the … window used`, with a `/compact` hint past 80%), so the agent can check both cost and remaining room before committing to expensive work — a wide subagent fan-out, a large read — instead of finding out afterwards.
99
103
 
100
104
  ## License
101
105
 
@@ -29,7 +29,7 @@ import { join } from "node:path";
29
29
  import { readFileSync } from "node:fs";
30
30
 
31
31
  import { addRecord, aggregate, recordFromEntry, windowTotals } from "../src/aggregate.ts";
32
- import { buildBreakdown, formatBreakdown } from "../src/context.ts";
32
+ import { buildBreakdown, contextTokensFromUsage, formatBreakdown, resolveUsedTokens } from "../src/context.ts";
33
33
  import { contextGauge, footerText, formatCost, formatTokens, historyBlock, sessionBlock } from "../src/format.ts";
34
34
  import { QUOTA_PROVIDERS, fetchQuota, quotaReport, type QuotaResult } from "../src/quota.ts";
35
35
  import { redact } from "../src/redact.ts";
@@ -41,7 +41,12 @@ type UiContext = ExtensionContext;
41
41
 
42
42
  export default function usage(pi: ExtensionAPI) {
43
43
  let session: UsageTotals = emptyTotals();
44
- /** input+cacheRead of the most recent assistant message ≈ context size. */
44
+ /**
45
+ * The most recent assistant turn's context size, via pi's own
46
+ * calculateContextTokens (totalTokens, or input+output+cacheRead+cacheWrite).
47
+ * Cleared on compaction until the next post-compaction response, matching
48
+ * what pi's getContextUsage reports.
49
+ */
45
50
  let lastPromptTokens = 0;
46
51
  /**
47
52
  * The latest rate-limit headers seen per provider — passive quota, captured
@@ -68,14 +73,31 @@ export default function usage(pi: ExtensionAPI) {
68
73
  ctx.ui.setStatus("usage", undefined);
69
74
  return;
70
75
  }
71
- const gauge = contextGauge(contextPct(ctx));
76
+ const gauge = contextGauge(contextInfo(ctx).pct);
72
77
  ctx.ui.setStatus("usage", gauge ? `${base} · ${gauge}` : base);
73
78
  }
74
79
 
75
- function contextPct(ctx: UiContext): number | null {
76
- const window = (ctx.model as { contextWindow?: number } | null)?.contextWindow;
77
- if (!window || lastPromptTokens === 0) return null;
78
- return Math.min(100, (lastPromptTokens / window) * 100);
80
+ /**
81
+ * The context fill percentage and the window it is measured against. The
82
+ * window is returned alongside the percentage so callers (the usage_status
83
+ * tool) can name the window without recomputing it. `pct` is null when there
84
+ * is nothing to measure (no window, or nothing known about usage).
85
+ */
86
+ function contextInfo(ctx: UiContext): { pct: number | null; window: number } {
87
+ const usage = (
88
+ ctx as { getContextUsage?: () => { tokens?: number | null; contextWindow?: number; percent?: number | null } | undefined }
89
+ ).getContextUsage?.();
90
+ const window = usage?.contextWindow ?? (ctx.model as { contextWindow?: number } | null)?.contextWindow ?? 0;
91
+ // Reconcile pi's own reading with the provider's last-request report, rather
92
+ // than trusting the provider number blind (some backends report it wrong).
93
+ const used = resolveUsedTokens({
94
+ hostTokens: usage?.tokens ?? null,
95
+ hostPercent: usage?.percent ?? null,
96
+ window,
97
+ providerTokens: lastPromptTokens || null,
98
+ });
99
+ if (used === null || window <= 0) return { pct: null, window };
100
+ return { pct: Math.min(100, (used / window) * 100), window };
79
101
  }
80
102
 
81
103
  function dashboard(ctx: UiContext): string {
@@ -83,7 +105,7 @@ export default function usage(pi: ExtensionAPI) {
83
105
  const scan = scanSessions(join(getAgentDir(), "sessions"));
84
106
  return [scan.records, scan.files] as const;
85
107
  })());
86
- return [sessionBlock(session, contextPct(ctx)), historyBlock(history, Date.now())].join("\n\n");
108
+ return [sessionBlock(session, contextInfo(ctx).pct), historyBlock(history, Date.now())].join("\n\n");
87
109
  }
88
110
 
89
111
  // ── Live tracking ────────────────────────────────────────────────────
@@ -94,10 +116,10 @@ export default function usage(pi: ExtensionAPI) {
94
116
  addRecord(session, record);
95
117
  const message = (event as { message?: { role?: string; usage?: unknown } }).message;
96
118
  if (message?.role === "assistant" && isRecord(message.usage)) {
97
- const input = message.usage.input;
98
- const cacheRead = message.usage.cacheRead;
99
- lastPromptTokens =
100
- (typeof input === "number" ? input : 0) + (typeof cacheRead === "number" ? cacheRead : 0);
119
+ // Use pi's own formula so the gauge cannot drift below pi's reading —
120
+ // input+cacheRead alone dropped cacheWrite and output, a real slice of
121
+ // every Anthropic prompt-cached turn.
122
+ lastPromptTokens = contextTokensFromUsage(message.usage);
101
123
  }
102
124
  updateFooter(ctx);
103
125
  });
@@ -113,6 +135,31 @@ export default function usage(pi: ExtensionAPI) {
113
135
  updateFooter(ctx);
114
136
  });
115
137
 
138
+ pi.on("session_compact", async (event, ctx) => {
139
+ // pi's getContextUsage returns {tokens:null} until the first post-compaction
140
+ // response, so the stale pre-compaction figure would otherwise keep the
141
+ // gauge and /context at the old ~90% right after /compact. Drop it and let
142
+ // the local estimate drive the number until the next assistant reply.
143
+ lastPromptTokens = 0;
144
+ // The summariser's own call is one of the priciest turns in a session and
145
+ // fires no message_end (it is a direct completion, not the agent loop), so
146
+ // fold its usage — carried on the compaction entry itself — into the live
147
+ // footer and session totals here.
148
+ const record = recordFromEntry((event as { compactionEntry?: unknown }).compactionEntry);
149
+ if (record) addRecord(session, record);
150
+ updateFooter(ctx);
151
+ });
152
+
153
+ pi.on("session_tree", async (event, ctx) => {
154
+ // A branch summary is generated the same way (a direct completion), with its
155
+ // usage on the summary entry; count it so tree navigation is not free.
156
+ const record = recordFromEntry((event as { summaryEntry?: unknown }).summaryEntry);
157
+ if (record) {
158
+ addRecord(session, record);
159
+ updateFooter(ctx);
160
+ }
161
+ });
162
+
116
163
  pi.on("session_shutdown", async (_event, ctx) => {
117
164
  if (ctx.hasUI) ctx.ui.setStatus("usage", undefined);
118
165
  });
@@ -173,7 +220,7 @@ export default function usage(pi: ExtensionAPI) {
173
220
  skills?: unknown[];
174
221
  selectedTools?: string[];
175
222
  };
176
- getContextUsage?: () => { contextWindow?: number; used?: number; total?: number } | undefined;
223
+ getContextUsage?: () => { tokens?: number | null; contextWindow?: number; percent?: number | null } | undefined;
177
224
  sessionManager?: { buildContextEntries?: () => unknown[]; getBranch?: () => unknown[] };
178
225
  };
179
226
 
@@ -204,7 +251,17 @@ export default function usage(pi: ExtensionAPI) {
204
251
  contextWindow,
205
252
  reserveTokens: compactionReserve(ctx),
206
253
  });
207
- const reported = typeof usage?.used === "number" ? usage.used : lastPromptTokens || null;
254
+ // The provider's real last-request number is the primary "used" figure (it
255
+ // makes the "Other" row — what the provider counts that we can't attribute —
256
+ // meaningful), reconciled against pi's own reading and floored by the local
257
+ // estimate so a wrong provider total can't over- or under-state the window.
258
+ const reported = resolveUsedTokens({
259
+ hostTokens: usage?.tokens ?? null,
260
+ hostPercent: usage?.percent ?? null,
261
+ window: contextWindow,
262
+ providerTokens: lastPromptTokens || null,
263
+ estimate: breakdown.attributed,
264
+ });
208
265
  return formatBreakdown(breakdown, reported);
209
266
  }
210
267
 
@@ -268,21 +325,30 @@ export default function usage(pi: ExtensionAPI) {
268
325
  label: "Usage status",
269
326
  promptSnippet: "Tokens, cost, and context used so far this session",
270
327
  description:
271
- "Current session token/cost totals plus today's local aggregate. Use when deciding whether " +
272
- "an expensive approach (large reads, many subagents) is proportionate.",
328
+ "Current session token/cost totals, today's local aggregate, and how full the context " +
329
+ "window is. Use when deciding whether an expensive approach (large reads, many subagents) " +
330
+ "is proportionate, or whether to /compact first.",
273
331
  parameters: Type.Object({}),
274
332
  async execute(_id, _params, _signal, _onUpdate, ctx) {
275
333
  const scan = scanSessions(join(getAgentDir(), "sessions"));
276
334
  const history = aggregate(scan.records, scan.files);
277
335
  const today = windowTotals(history.byDay, 1, Date.now());
278
- const text = [
336
+ const lines = [
279
337
  `Session: ${formatTokens(session.totalTokens)} tokens, ${formatCost(session.cost)} across ${session.messages} responses.`,
280
338
  `Today (all sessions): ${formatTokens(today.totalTokens)} tokens, ${formatCost(today.cost)}.`,
281
- ].join("\n");
282
- void ctx;
339
+ ];
340
+ // The snippet promises "context used", and the skill tells the model to
341
+ // call this before large reads — so return the one number that decides
342
+ // whether the read fits, with a hint to compact when the window is tight.
343
+ const { pct, window } = contextInfo(ctx);
344
+ const contextPercent = pct === null ? null : Math.round(pct);
345
+ if (pct !== null) {
346
+ const hint = pct >= 80 ? " — consider /compact before large reads" : "";
347
+ lines.push(`Context: ~${contextPercent}% of the ${formatTokens(window)} window used${hint}.`);
348
+ }
283
349
  return {
284
- content: [{ type: "text", text }],
285
- details: { session, today },
350
+ content: [{ type: "text", text: lines.join("\n") }],
351
+ details: { session, today, contextPercent, contextWindow: window },
286
352
  };
287
353
  },
288
354
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/usage",
3
- "version": "0.8.0",
3
+ "version": "0.9.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/aggregate.ts CHANGED
@@ -71,16 +71,27 @@ export function windowTotals(byDay: Map<string, UsageTotals>, days: number, now:
71
71
  }
72
72
 
73
73
  /**
74
- * Extract a usage record from one parsed session-JSONL entry. Counts any
75
- * entry whose message carries a usage block (assistant turns dominate; pi
76
- * 0.81+ also persists tool-result/compaction usage the same way). Returns
77
- * null for entries without usage. Negative/NaN fields clamp to 0.
74
+ * Extract a usage record from one parsed session-JSONL entry. Assistant turns
75
+ * and tool results carry their usage on `entry.message.usage`. pi stores the
76
+ * summariser's usage one level up, on the compaction / branch_summary entry
77
+ * itself (these entries have no `message`), so those are read from
78
+ * `entry.usage` — otherwise the most expensive calls in a session go
79
+ * uncounted. Returns null for entries without usage; negative/NaN fields clamp
80
+ * to 0.
78
81
  */
79
82
  export function recordFromEntry(entry: unknown): UsageRecord | null {
80
83
  if (!isRecord(entry)) return null;
81
- const message = entry.message;
82
- if (!isRecord(message) || !isRecord(message.usage)) return null;
83
- const usage = message.usage;
84
+ const message = isRecord(entry.message) ? entry.message : null;
85
+ const entryType = typeof entry.type === "string" ? entry.type : "";
86
+ const isSummary = entryType === "compaction" || entryType === "branch_summary";
87
+ const usage =
88
+ message && isRecord(message.usage)
89
+ ? message.usage
90
+ : isSummary && isRecord(entry.usage)
91
+ ? entry.usage
92
+ : null;
93
+ if (!usage) return null;
94
+
84
95
  const total = finite(usage.totalTokens);
85
96
  const cost = isRecord(usage.cost) ? finite(usage.cost.total) : 0;
86
97
  if (total === 0 && cost === 0) return null;
@@ -88,14 +99,20 @@ export function recordFromEntry(entry: unknown): UsageRecord | null {
88
99
  const ts =
89
100
  typeof entry.timestamp === "string"
90
101
  ? Date.parse(entry.timestamp)
91
- : typeof message.timestamp === "number"
102
+ : message && typeof message.timestamp === "number"
92
103
  ? message.timestamp
93
104
  : Number.NaN;
94
105
 
106
+ // A summary entry has no model/provider of its own; label the bucket by the
107
+ // entry type rather than threading a running "last model" through the scan.
108
+ const model =
109
+ message && typeof message.model === "string" ? message.model : isSummary ? entryType : "unknown";
110
+ const provider = message && typeof message.provider === "string" ? message.provider : "unknown";
111
+
95
112
  return {
96
113
  timestamp: Number.isFinite(ts) ? ts : 0,
97
- model: typeof message.model === "string" ? message.model : "unknown",
98
- provider: typeof message.provider === "string" ? message.provider : "unknown",
114
+ model,
115
+ provider,
99
116
  project: "",
100
117
  input: finite(usage.input),
101
118
  output: finite(usage.output),
package/src/context.ts CHANGED
@@ -37,6 +37,58 @@ export function estimateTextTokens(value: unknown): number {
37
37
  return Math.max(0, Math.ceil(text.length / 4));
38
38
  }
39
39
 
40
+ function nonNegNumber(value: unknown): number {
41
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
42
+ }
43
+
44
+ /**
45
+ * The context size pi itself derives from a usage block — its own
46
+ * calculateContextTokens: `totalTokens` when present, else the sum of input +
47
+ * output + cacheRead + cacheWrite. Kept identical here so the footer gauge and
48
+ * /context agree with pi's reading; the earlier input+cacheRead formula dropped
49
+ * cacheWrite and output, which on Anthropic prompt caching are a real slice of
50
+ * every turn, leaving the gauge systematically low.
51
+ */
52
+ export function contextTokensFromUsage(usage: unknown): number {
53
+ if (!isRecord(usage)) return 0;
54
+ const total = nonNegNumber(usage.totalTokens);
55
+ if (total > 0) return total;
56
+ return (
57
+ nonNegNumber(usage.input) +
58
+ nonNegNumber(usage.output) +
59
+ nonNegNumber(usage.cacheRead) +
60
+ nonNegNumber(usage.cacheWrite)
61
+ );
62
+ }
63
+
64
+ /** pi's fixed per-image charge; a base64 blob is never billed as its bytes. */
65
+ const ESTIMATED_IMAGE_CHARS = 4800;
66
+
67
+ /**
68
+ * Character count of message content the way pi's compaction estimator counts
69
+ * it (estimateTextAndImageContentChars): a string is its own length; a content
70
+ * array charges each text block's text and a flat 4800 chars per image block,
71
+ * ignoring every other field. Without this, JSON.stringify would bill an
72
+ * ImageContent block's base64 `data` at chars/4 — one screenshot then reads as
73
+ * hundreds of k tokens and pins /context at 100%.
74
+ */
75
+ export function contentChars(content: unknown): number {
76
+ if (typeof content === "string") return content.length;
77
+ if (!Array.isArray(content)) return 0;
78
+ let chars = 0;
79
+ for (const block of content) {
80
+ if (!isRecord(block)) continue;
81
+ if (block.type === "text" && typeof block.text === "string") chars += block.text.length;
82
+ else if (block.type === "image") chars += ESTIMATED_IMAGE_CHARS;
83
+ }
84
+ return chars;
85
+ }
86
+
87
+ /** contentChars in tokens (chars/4), matching pi's estimateTokens. */
88
+ function contentTokens(content: unknown): number {
89
+ return Math.ceil(contentChars(content) / 4);
90
+ }
91
+
40
92
  /** Count a chunk only when the assembled prompt really contains it. */
41
93
  export function embeddedTokens(systemPrompt: string, chunk: string): number {
42
94
  if (!chunk || !systemPrompt.includes(chunk)) return 0;
@@ -78,7 +130,10 @@ function foldEntries(entries: unknown[]): { conversation: number; toolResults: n
78
130
  const role = typeof message.role === "string" ? message.role : null;
79
131
 
80
132
  if (role === "toolResult" || role === "bashExecution") {
81
- toolResults += estimateTextTokens(message.content ?? message.output ?? message);
133
+ // Tool results carry image blocks (pi's read tool returns them); charge
134
+ // those at pi's flat rate rather than the base64 bytes' chars/4.
135
+ const content = message.content ?? message.output;
136
+ toolResults += content === undefined ? estimateTextTokens(message) : contentTokens(content);
82
137
  continue;
83
138
  }
84
139
  if (role === "assistant" && Array.isArray(message.content)) {
@@ -102,10 +157,14 @@ function foldEntries(entries: unknown[]): { conversation: number; toolResults: n
102
157
  continue;
103
158
  }
104
159
  if (typeof entry.content === "string" || Array.isArray(entry.content)) {
105
- conversation += estimateTextTokens(entry.content);
160
+ // A raw user message; its content may hold a pasted screenshot.
161
+ conversation += contentTokens(entry.content);
106
162
  continue;
107
163
  }
108
- if (role) conversation += estimateTextTokens(message.content ?? message);
164
+ if (role) {
165
+ conversation +=
166
+ message.content === undefined ? estimateTextTokens(message) : contentTokens(message.content);
167
+ }
109
168
  }
110
169
 
111
170
  return { conversation, toolResults, thinking };
@@ -149,6 +208,62 @@ export function buildBreakdown(input: BreakdownInput): ContextBreakdown {
149
208
  };
150
209
  }
151
210
 
211
+ export interface UsedTokensInput {
212
+ /** pi's own estimate of context tokens (getContextUsage().tokens), or null. */
213
+ hostTokens: number | null;
214
+ /** pi's own percent-of-window (getContextUsage().percent), or null. */
215
+ hostPercent: number | null;
216
+ /** The context window; 0 when unknown. */
217
+ window: number;
218
+ /**
219
+ * The provider's report for the last request, via pi's own
220
+ * calculateContextTokens (totalTokens, or input+output+cacheRead+cacheWrite),
221
+ * or null.
222
+ */
223
+ providerTokens: number | null;
224
+ /** A local content estimate (a hard lower bound), when one is available. */
225
+ estimate?: number;
226
+ }
227
+
228
+ /**
229
+ * Reconcile the several "how full is the window" signals into one used-tokens
230
+ * figure, so the gauge does not blindly trust a provider number that some
231
+ * backends report wrong (cumulative totals, cache-inflated counts, or an
232
+ * implausibly small figure). The rule — from minuque/pi-cc-extensions'
233
+ * resolveUsedTokens:
234
+ *
235
+ * - the provider's report is the primary signal (it is the real last request),
236
+ * - but when it diverges beyond tolerance from pi's own percent×window reading
237
+ * (what pi itself uses for the gauge and for compaction), trust pi's number,
238
+ * - and never report fewer tokens than the content visibly in context.
239
+ *
240
+ * Returns null only when nothing is known (so the gauge can hide itself).
241
+ */
242
+ export function resolveUsedTokens(input: UsedTokensInput): number | null {
243
+ const w = input.window > 0 ? input.window : 0;
244
+ const fromPercent = input.hostPercent !== null && w > 0 ? (input.hostPercent / 100) * w : null;
245
+ const host = input.hostTokens !== null ? input.hostTokens : fromPercent;
246
+ const provider = input.providerTokens !== null && input.providerTokens > 0 ? input.providerTokens : null;
247
+ const estimate = typeof input.estimate === "number" && input.estimate > 0 ? input.estimate : null;
248
+
249
+ let used = provider ?? host ?? estimate;
250
+ if (used === null || used === undefined) return null;
251
+
252
+ // Trust pi's own reading when the provider's number diverges beyond tolerance
253
+ // (25% of the host reading, or 2k tokens, whichever is larger).
254
+ if (provider !== null && host !== null) {
255
+ const tolerance = Math.max(2000, host * 0.25);
256
+ if (Math.abs(provider - host) > tolerance) used = host;
257
+ }
258
+
259
+ // A content estimate is a hard floor: you cannot be using fewer tokens than
260
+ // the text that is demonstrably in context.
261
+ if (estimate !== null && estimate > used) used = estimate;
262
+
263
+ if (w > 0) used = Math.min(used, w);
264
+ return Math.max(0, Math.round(used));
265
+ }
266
+
152
267
  const BAR_WIDTH = 28;
153
268
 
154
269
  function bar(fraction: number): string {
@@ -176,7 +291,10 @@ function formatTokens(n: number): string {
176
291
  */
177
292
  export function formatBreakdown(breakdown: ContextBreakdown, reportedUsed: number | null): string {
178
293
  const window = breakdown.contextWindow;
179
- const used = Math.max(reportedUsed ?? 0, breakdown.attributed);
294
+ // Clamp to the window so a stale or odd figure can never print above 100%
295
+ // (resolveUsedTokens already clamps its own result, but `attributed` does not).
296
+ const raw = Math.max(reportedUsed ?? 0, breakdown.attributed);
297
+ const used = window > 0 ? Math.min(window, raw) : raw;
180
298
  const other = Math.max(0, used - breakdown.attributed);
181
299
  // pi holds back a reserve for auto-compaction; real headroom excludes it, so
182
300
  // show it as its own row and subtract it from free space rather than letting
package/src/ratelimit.ts CHANGED
@@ -23,8 +23,14 @@ export interface RateLimitSnapshot {
23
23
  tokensRemaining: number | null;
24
24
  /** When the window resets — provider's own string (seconds, ISO, or duration). */
25
25
  resets: string | null;
26
- /** A unified subscription-window fraction 0..1 remaining, Anthropic OAuth only. */
27
- unifiedRemaining: number | null;
26
+ /** Fraction 0..1 of the unified 5-hour subscription window still free (Anthropic OAuth). */
27
+ unified5hRemaining: number | null;
28
+ /** Fraction 0..1 of the unified 7-day subscription window still free (Anthropic OAuth). */
29
+ unified7dRemaining: number | null;
30
+ /** Which unified window Anthropic marks as binding, from -representative-claim. */
31
+ unifiedBinding: "5h" | "7d" | null;
32
+ /** Unified subscription status: "allowed" | "allowed_warning" | "rejected". */
33
+ unifiedStatus: string | null;
28
34
  /** Which header family this came from, for the report line. */
29
35
  source: "anthropic" | "openai" | "openrouter";
30
36
  capturedAtMs: number;
@@ -37,6 +43,14 @@ function intOf(headers: Record<string, string>, key: string): number | null {
37
43
  return Number.isFinite(n) ? n : null;
38
44
  }
39
45
 
46
+ /** A fraction 0..1 (Anthropic reports utilization as a decimal fraction USED). */
47
+ function fracOf(headers: Record<string, string>, key: string): number | null {
48
+ const raw = headers[key] ?? headers[key.toLowerCase()];
49
+ if (raw === undefined) return null;
50
+ const n = Number.parseFloat(String(raw));
51
+ return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : null;
52
+ }
53
+
40
54
  function strOf(headers: Record<string, string>, key: string): string | null {
41
55
  const raw = headers[key] ?? headers[key.toLowerCase()];
42
56
  return raw === undefined || raw === "" ? null : String(raw);
@@ -69,20 +83,30 @@ export function parseRateLimit(
69
83
  if (family === "anthropic") {
70
84
  const reqs = intOf(h, "anthropic-ratelimit-requests-remaining");
71
85
  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) {
86
+ // The OAuth Pro/Max unified subscription windows. Anthropic reports
87
+ // UTILIZATION — the fraction USED, 0..1 — for a 5-hour and a 7-day window
88
+ // (there is no "-remaining" header), plus which window is binding
89
+ // (-representative-claim) and an overall status. Remaining = 1 − utilization.
90
+ const util5h = fracOf(h, "anthropic-ratelimit-unified-5h-utilization");
91
+ const util7d = fracOf(h, "anthropic-ratelimit-unified-7d-utilization");
92
+ const claim = strOf(h, "anthropic-ratelimit-unified-representative-claim");
93
+ const unifiedStatus = strOf(h, "anthropic-ratelimit-unified-status");
94
+ const hasUnified = util5h !== null || util7d !== null || unifiedStatus !== null;
95
+ if (reqs !== null || toks !== null || hasUnified) {
77
96
  snap = {
78
97
  provider,
79
98
  requestsRemaining: reqs,
80
99
  tokensRemaining: toks,
81
100
  resets:
82
101
  strOf(h, "anthropic-ratelimit-unified-reset") ??
102
+ strOf(h, "anthropic-ratelimit-unified-5h-reset") ??
103
+ strOf(h, "anthropic-ratelimit-unified-7d-reset") ??
83
104
  strOf(h, "anthropic-ratelimit-tokens-reset") ??
84
105
  strOf(h, "anthropic-ratelimit-requests-reset"),
85
- unifiedRemaining: unifiedRaw === null ? null : Math.max(0, Math.min(1, unifiedRaw / 100)),
106
+ unified5hRemaining: util5h === null ? null : 1 - util5h,
107
+ unified7dRemaining: util7d === null ? null : 1 - util7d,
108
+ unifiedBinding: claim === "5h" || claim === "7d" ? claim : null,
109
+ unifiedStatus,
86
110
  source: "anthropic",
87
111
  capturedAtMs: nowMs,
88
112
  };
@@ -96,7 +120,10 @@ export function parseRateLimit(
96
120
  requestsRemaining: reqs,
97
121
  tokensRemaining: toks,
98
122
  resets: strOf(h, "x-ratelimit-reset-tokens") ?? strOf(h, "x-ratelimit-reset-requests"),
99
- unifiedRemaining: null,
123
+ unified5hRemaining: null,
124
+ unified7dRemaining: null,
125
+ unifiedBinding: null,
126
+ unifiedStatus: null,
100
127
  source: "openai",
101
128
  capturedAtMs: nowMs,
102
129
  };
@@ -110,7 +137,10 @@ export function parseRateLimit(
110
137
  requestsRemaining: remaining,
111
138
  tokensRemaining: null,
112
139
  resets: strOf(h, "x-ratelimit-reset"),
113
- unifiedRemaining: null,
140
+ unified5hRemaining: null,
141
+ unified7dRemaining: null,
142
+ unifiedBinding: null,
143
+ unifiedStatus: null,
114
144
  source: "openrouter",
115
145
  capturedAtMs: nowMs,
116
146
  };
@@ -120,14 +150,45 @@ export function parseRateLimit(
120
150
  return snap;
121
151
  }
122
152
 
153
+ /**
154
+ * Anthropic's unified reset is epoch SECONDS; render it as a readable local
155
+ * time. Other providers' values (ISO strings, "6s" durations, openrouter's
156
+ * epoch millis) are left untouched, so this only reinterprets the anthropic
157
+ * numeric case.
158
+ */
159
+ function formatReset(snap: RateLimitSnapshot): string {
160
+ const raw = snap.resets;
161
+ if (!raw) return "";
162
+ if (snap.source === "anthropic" && /^\d{9,}$/.test(raw)) {
163
+ const d = new Date(Number.parseInt(raw, 10) * 1000);
164
+ if (Number.isFinite(d.getTime())) return d.toLocaleString();
165
+ }
166
+ return raw;
167
+ }
168
+
123
169
  /** One human line for the /usage quota report; null when nothing was captured. */
124
170
  export function formatRateLimit(snap: RateLimitSnapshot | null): string | null {
125
171
  if (!snap) return null;
126
172
  const parts: string[] = [];
127
- if (snap.unifiedRemaining !== null) parts.push(`${Math.round(snap.unifiedRemaining * 100)}% of subscription window left`);
173
+ // Surface the subscription status only when it is not the ordinary "allowed",
174
+ // so a healthy window does not add noise but a warning/rejection is visible.
175
+ if (snap.unifiedStatus && snap.unifiedStatus !== "allowed") {
176
+ parts.push(`subscription ${snap.unifiedStatus.replace(/_/g, " ")}`);
177
+ }
178
+ if (snap.unified5hRemaining !== null) {
179
+ parts.push(
180
+ `${Math.round(snap.unified5hRemaining * 100)}% of 5h window left${snap.unifiedBinding === "5h" ? " (binding)" : ""}`,
181
+ );
182
+ }
183
+ if (snap.unified7dRemaining !== null) {
184
+ parts.push(
185
+ `${Math.round(snap.unified7dRemaining * 100)}% of 7d window left${snap.unifiedBinding === "7d" ? " (binding)" : ""}`,
186
+ );
187
+ }
128
188
  if (snap.requestsRemaining !== null) parts.push(`${snap.requestsRemaining} requests left`);
129
189
  if (snap.tokensRemaining !== null) parts.push(`${snap.tokensRemaining} tokens left`);
130
190
  if (parts.length === 0) return null;
131
- const when = snap.resets ? `, resets ${snap.resets}` : "";
191
+ const reset = formatReset(snap);
192
+ const when = reset ? `, resets ${reset}` : "";
132
193
  return `${snap.provider}: ${parts.join(", ")}${when} (from response headers)`;
133
194
  }