@pify/usage 0.5.1 → 0.6.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 +2 -0
- package/extensions/usage.ts +31 -2
- package/package.json +1 -1
- package/src/quota.ts +1 -1
- package/src/ratelimit.ts +133 -0
package/README.md
CHANGED
|
@@ -73,6 +73,8 @@ Quota (DeepSeek · granted 10 · topped up 100)
|
|
|
73
73
|
|
|
74
74
|
**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
75
|
|
|
76
|
+
**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.
|
|
77
|
+
|
|
76
78
|
**A credentialed request is pinned down**, because it carries your provider key:
|
|
77
79
|
|
|
78
80
|
- HTTPS only, and the host must be on that provider's allowlist.
|
package/extensions/usage.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { footerText, formatCost, formatTokens, historyBlock, sessionBlock } from
|
|
|
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,6 +42,23 @@ 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;
|
|
@@ -182,7 +200,13 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
182
200
|
if (key) configured.push({ provider, key });
|
|
183
201
|
}
|
|
184
202
|
if (configured.length === 0) {
|
|
185
|
-
|
|
203
|
+
const passive = [...rateLimits.values()]
|
|
204
|
+
.map((s) => formatRateLimit(s))
|
|
205
|
+
.filter((line): line is string => line !== null);
|
|
206
|
+
ctx.ui.notify(
|
|
207
|
+
passive.length > 0 ? redact(`${passive.join("\n")}\n\n${quotaReport([])}`) : quotaReport([]),
|
|
208
|
+
"info",
|
|
209
|
+
);
|
|
186
210
|
return;
|
|
187
211
|
}
|
|
188
212
|
ctx.ui.notify(`Checking quota for ${configured.map((c) => c.provider.displayName).join(", ")}…`, "info");
|
|
@@ -190,7 +214,11 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
190
214
|
for (const { provider, key } of configured) {
|
|
191
215
|
results.push(await fetchQuota(provider, key));
|
|
192
216
|
}
|
|
193
|
-
|
|
217
|
+
const passive = [...rateLimits.values()]
|
|
218
|
+
.map((s) => formatRateLimit(s))
|
|
219
|
+
.filter((line): line is string => line !== null);
|
|
220
|
+
const passiveBlock = passive.length > 0 ? `${passive.join("\n")}\n\n` : "";
|
|
221
|
+
ctx.ui.notify(redact(passiveBlock + quotaReport(results)), results.every((r) => r.ok) ? "info" : "warning");
|
|
194
222
|
return;
|
|
195
223
|
}
|
|
196
224
|
ctx.ui.notify(dashboard(ctx), "info");
|
|
@@ -200,6 +228,7 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
200
228
|
pi.registerTool({
|
|
201
229
|
name: "usage_status",
|
|
202
230
|
label: "Usage status",
|
|
231
|
+
promptSnippet: "Tokens, cost, and context used so far this session",
|
|
203
232
|
description:
|
|
204
233
|
"Current session token/cost totals plus today's local aggregate. Use when deciding whether " +
|
|
205
234
|
"an expensive approach (large reads, many subagents) is proportionate.",
|
package/package.json
CHANGED
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
|
+
}
|