@pify/usage 0.8.1 → 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 +7 -5
- package/extensions/usage.ts +63 -18
- package/package.json +1 -1
- package/src/aggregate.ts +27 -10
- package/src/context.ts +71 -5
- package/src/ratelimit.ts +73 -12
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
|
|
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,9 +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
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. (The reconciliation rule is from minuque/pi-cc-extensions.)
|
|
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.
|
|
66
68
|
|
|
67
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.
|
|
68
70
|
|
|
@@ -81,7 +83,7 @@ Quota (DeepSeek · granted 10 · topped up 100)
|
|
|
81
83
|
|
|
82
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.
|
|
83
85
|
|
|
84
|
-
**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
|
|
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.
|
|
85
87
|
|
|
86
88
|
**A credentialed request is pinned down**, because it carries your provider key:
|
|
87
89
|
|
|
@@ -97,7 +99,7 @@ Quota (DeepSeek · granted 10 · topped up 100)
|
|
|
97
99
|
|
|
98
100
|
### `usage_status`
|
|
99
101
|
|
|
100
|
-
No parameters. Returns session and today's totals, so the agent can check
|
|
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.
|
|
101
103
|
|
|
102
104
|
## License
|
|
103
105
|
|
package/extensions/usage.ts
CHANGED
|
@@ -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, resolveUsedTokens } 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
|
-
/**
|
|
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,11 +73,17 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
68
73
|
ctx.ui.setStatus("usage", undefined);
|
|
69
74
|
return;
|
|
70
75
|
}
|
|
71
|
-
const gauge = contextGauge(
|
|
76
|
+
const gauge = contextGauge(contextInfo(ctx).pct);
|
|
72
77
|
ctx.ui.setStatus("usage", gauge ? `${base} · ${gauge}` : base);
|
|
73
78
|
}
|
|
74
79
|
|
|
75
|
-
|
|
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 } {
|
|
76
87
|
const usage = (
|
|
77
88
|
ctx as { getContextUsage?: () => { tokens?: number | null; contextWindow?: number; percent?: number | null } | undefined }
|
|
78
89
|
).getContextUsage?.();
|
|
@@ -85,8 +96,8 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
85
96
|
window,
|
|
86
97
|
providerTokens: lastPromptTokens || null,
|
|
87
98
|
});
|
|
88
|
-
if (used === null || window <= 0) return null;
|
|
89
|
-
return Math.min(100, (used / window) * 100);
|
|
99
|
+
if (used === null || window <= 0) return { pct: null, window };
|
|
100
|
+
return { pct: Math.min(100, (used / window) * 100), window };
|
|
90
101
|
}
|
|
91
102
|
|
|
92
103
|
function dashboard(ctx: UiContext): string {
|
|
@@ -94,7 +105,7 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
94
105
|
const scan = scanSessions(join(getAgentDir(), "sessions"));
|
|
95
106
|
return [scan.records, scan.files] as const;
|
|
96
107
|
})());
|
|
97
|
-
return [sessionBlock(session,
|
|
108
|
+
return [sessionBlock(session, contextInfo(ctx).pct), historyBlock(history, Date.now())].join("\n\n");
|
|
98
109
|
}
|
|
99
110
|
|
|
100
111
|
// ── Live tracking ────────────────────────────────────────────────────
|
|
@@ -105,10 +116,10 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
105
116
|
addRecord(session, record);
|
|
106
117
|
const message = (event as { message?: { role?: string; usage?: unknown } }).message;
|
|
107
118
|
if (message?.role === "assistant" && isRecord(message.usage)) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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);
|
|
112
123
|
}
|
|
113
124
|
updateFooter(ctx);
|
|
114
125
|
});
|
|
@@ -124,6 +135,31 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
124
135
|
updateFooter(ctx);
|
|
125
136
|
});
|
|
126
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
|
+
|
|
127
163
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
128
164
|
if (ctx.hasUI) ctx.ui.setStatus("usage", undefined);
|
|
129
165
|
});
|
|
@@ -289,21 +325,30 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
289
325
|
label: "Usage status",
|
|
290
326
|
promptSnippet: "Tokens, cost, and context used so far this session",
|
|
291
327
|
description:
|
|
292
|
-
"Current session token/cost totals
|
|
293
|
-
"an expensive approach (large reads, many subagents)
|
|
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.",
|
|
294
331
|
parameters: Type.Object({}),
|
|
295
332
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
296
333
|
const scan = scanSessions(join(getAgentDir(), "sessions"));
|
|
297
334
|
const history = aggregate(scan.records, scan.files);
|
|
298
335
|
const today = windowTotals(history.byDay, 1, Date.now());
|
|
299
|
-
const
|
|
336
|
+
const lines = [
|
|
300
337
|
`Session: ${formatTokens(session.totalTokens)} tokens, ${formatCost(session.cost)} across ${session.messages} responses.`,
|
|
301
338
|
`Today (all sessions): ${formatTokens(today.totalTokens)} tokens, ${formatCost(today.cost)}.`,
|
|
302
|
-
]
|
|
303
|
-
|
|
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
|
+
}
|
|
304
349
|
return {
|
|
305
|
-
content: [{ type: "text", text }],
|
|
306
|
-
details: { session, today },
|
|
350
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
351
|
+
details: { session, today, contextPercent, contextWindow: window },
|
|
307
352
|
};
|
|
308
353
|
},
|
|
309
354
|
});
|
package/package.json
CHANGED
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.
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
-
|
|
83
|
-
const
|
|
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
|
|
98
|
-
provider
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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 };
|
|
@@ -156,7 +215,11 @@ export interface UsedTokensInput {
|
|
|
156
215
|
hostPercent: number | null;
|
|
157
216
|
/** The context window; 0 when unknown. */
|
|
158
217
|
window: number;
|
|
159
|
-
/**
|
|
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
|
+
*/
|
|
160
223
|
providerTokens: number | null;
|
|
161
224
|
/** A local content estimate (a hard lower bound), when one is available. */
|
|
162
225
|
estimate?: number;
|
|
@@ -228,7 +291,10 @@ function formatTokens(n: number): string {
|
|
|
228
291
|
*/
|
|
229
292
|
export function formatBreakdown(breakdown: ContextBreakdown, reportedUsed: number | null): string {
|
|
230
293
|
const window = breakdown.contextWindow;
|
|
231
|
-
|
|
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;
|
|
232
298
|
const other = Math.max(0, used - breakdown.attributed);
|
|
233
299
|
// pi holds back a reserve for auto-compaction; real headroom excludes it, so
|
|
234
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
|
-
/**
|
|
27
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
191
|
+
const reset = formatReset(snap);
|
|
192
|
+
const when = reset ? `, resets ${reset}` : "";
|
|
132
193
|
return `${snap.provider}: ${parts.join(", ")}${when} (from response headers)`;
|
|
133
194
|
}
|