@pify/usage 0.4.0 → 0.5.1
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 +46 -16
- package/extensions/usage.ts +37 -11
- package/package.json +1 -1
- package/skills/usage/SKILL.md +1 -1
- package/src/http.ts +134 -0
- package/src/quota.ts +155 -51
- package/src/redact.ts +41 -0
package/README.md
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
# @pify/usage
|
|
2
2
|
|
|
3
|
-
Token and cost reporting for [pi](https://github.com/earendil-works/pi) sessions — a live footer, a `/usage` dashboard, and an agent-callable status tool.
|
|
3
|
+
Token and cost reporting for [pi](https://github.com/earendil-works/pi) sessions — a live footer, a `/usage` dashboard, and an agent-callable status tool. Local by default: no network calls, and no LLM tokens spent asking about tokens.
|
|
4
4
|
|
|
5
5
|
Part of the [Pify suite](https://github.com/pifydev). Install with [`pify install usage`](https://github.com/pifydev/cli) or `pi install npm:@pify/usage`.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Why
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
Spend is invisible until the invoice arrives, and by then you cannot tell which session did it. Everything needed to answer that is already on disk — pi writes usage and cost into every session file — so the answer should cost nothing to produce.
|
|
10
|
+
|
|
11
|
+
## Live footer
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
📊 12.3k tok · $0.45
|
|
15
|
+
```
|
|
16
|
+
|
|
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
|
+
|
|
19
|
+
## `/usage`
|
|
11
20
|
|
|
12
21
|
```
|
|
13
22
|
Session
|
|
@@ -27,8 +36,11 @@ By project (all time)
|
|
|
27
36
|
D--project-shop-api $6.60 · 3.6M tok
|
|
28
37
|
```
|
|
29
38
|
|
|
30
|
-
|
|
31
|
-
|
|
39
|
+
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.
|
|
40
|
+
|
|
41
|
+
Per-project totals come for free: pi stores sessions one directory per project, so the dashboard can show where the money actually went.
|
|
42
|
+
|
|
43
|
+
## `/usage context`
|
|
32
44
|
|
|
33
45
|
```
|
|
34
46
|
Context window: 22.6k of 200.0k used (11%)
|
|
@@ -40,24 +52,42 @@ Context window: 22.6k of 200.0k used (11%)
|
|
|
40
52
|
Free space █████████████████████████··· 89% 177.4k
|
|
41
53
|
```
|
|
42
54
|
|
|
43
|
-
|
|
55
|
+
"Why am I at 60%?" usually has a boring answer — one `read` of a 4,000-line file — and this is where you find it.
|
|
44
56
|
|
|
45
|
-
|
|
46
|
-
- **`usage_status` tool**: the agent can check session + today totals before committing to expensive work (subagent fan-outs, large reads).
|
|
57
|
+
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.
|
|
47
58
|
|
|
48
|
-
|
|
59
|
+
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.
|
|
49
60
|
|
|
50
|
-
|
|
61
|
+
## `/usage quota`
|
|
62
|
+
|
|
63
|
+
The one command here that touches the network, and only when you run it:
|
|
51
64
|
|
|
52
65
|
```
|
|
53
|
-
Quota (
|
|
54
|
-
spent $0.
|
|
55
|
-
window day $0.
|
|
66
|
+
Quota (OpenRouter · sk-or-v1-abc...xyz)
|
|
67
|
+
spent $0.33 (no credit limit on this key)
|
|
68
|
+
window day $0.01 · week $0.33 · month $0.33
|
|
69
|
+
|
|
70
|
+
Quota (DeepSeek · granted 10 · topped up 100)
|
|
71
|
+
balance CNY 110.00
|
|
56
72
|
```
|
|
57
73
|
|
|
58
|
-
|
|
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
|
+
|
|
76
|
+
**A credentialed request is pinned down**, because it carries your provider key:
|
|
77
|
+
|
|
78
|
+
- HTTPS only, and the host must be on that provider's allowlist.
|
|
79
|
+
- Redirects are refused outright. Following one lets whatever answered choose where the next request goes, with the header already attached.
|
|
80
|
+
- A non-2xx body is **never read**. Error bodies echo request details back, and an echoed `Authorization` header pasted into a notification is exactly the leak this must not cause — the status alone becomes the message.
|
|
81
|
+
- Raw exception text is dropped rather than shown, and everything printed passes a redactor as a last line of defence.
|
|
82
|
+
- An 8-second timeout per provider; any failure renders as `unavailable — the provider rejected the key` rather than throwing.
|
|
83
|
+
|
|
84
|
+
**The key comes from pi**, resolved through `modelRegistry` rather than by reading `auth.json`. pi owns credential storage — env precedence, OAuth, whatever it grows next — and parsing that file here would mean handling secrets this package has no business touching, using a stale copy of pi's rules. Only the provider's own masked label is ever printed.
|
|
85
|
+
|
|
86
|
+
## Tool
|
|
87
|
+
|
|
88
|
+
### `usage_status`
|
|
59
89
|
|
|
60
|
-
|
|
90
|
+
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.
|
|
61
91
|
|
|
62
92
|
## License
|
|
63
93
|
|
package/extensions/usage.ts
CHANGED
|
@@ -30,7 +30,8 @@ import { readFileSync } from "node:fs";
|
|
|
30
30
|
import { addRecord, aggregate, recordFromEntry, windowTotals } from "../src/aggregate.ts";
|
|
31
31
|
import { buildBreakdown, formatBreakdown } from "../src/context.ts";
|
|
32
32
|
import { footerText, formatCost, formatTokens, historyBlock, sessionBlock } from "../src/format.ts";
|
|
33
|
-
import {
|
|
33
|
+
import { QUOTA_PROVIDERS, fetchQuota, quotaReport, type QuotaResult } from "../src/quota.ts";
|
|
34
|
+
import { redact } from "../src/redact.ts";
|
|
34
35
|
import { scanSessions } from "../src/sessions.ts";
|
|
35
36
|
import { emptyTotals, isRecord, type UsageTotals } from "../src/types.ts";
|
|
36
37
|
|
|
@@ -97,15 +98,26 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
97
98
|
* The key pi itself uses, read from the same auth.json — no second place to
|
|
98
99
|
* configure credentials, and no key is ever printed.
|
|
99
100
|
*/
|
|
100
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Ask pi for the key rather than reading auth.json. pi owns credential
|
|
103
|
+
* storage — env precedence, OAuth, whatever it grows next — and parsing
|
|
104
|
+
* that file here meant handling secrets this package has no business
|
|
105
|
+
* touching, with a copy of pi's rules that would quietly go stale.
|
|
106
|
+
*/
|
|
107
|
+
async function providerKey(ctx: UiContext, provider: string): Promise<string> {
|
|
108
|
+
const registry = ctx.modelRegistry as unknown as {
|
|
109
|
+
getApiKeyForProvider?: (id: string) => Promise<string | undefined>;
|
|
110
|
+
getProviderAuth?: (id: string) => Promise<{ auth?: { apiKey?: string } } | undefined>;
|
|
111
|
+
};
|
|
101
112
|
try {
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
113
|
+
const direct = await registry.getApiKeyForProvider?.(provider);
|
|
114
|
+
if (direct) return direct;
|
|
115
|
+
const auth = await registry.getProviderAuth?.(provider);
|
|
116
|
+
if (auth?.auth?.apiKey) return auth.auth.apiKey;
|
|
105
117
|
} catch {
|
|
106
|
-
//
|
|
118
|
+
// an unconfigured provider is not an error here
|
|
107
119
|
}
|
|
108
|
-
return
|
|
120
|
+
return "";
|
|
109
121
|
}
|
|
110
122
|
|
|
111
123
|
/**
|
|
@@ -161,10 +173,24 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
161
173
|
handler: async (args, ctx) => {
|
|
162
174
|
if (!ctx.hasUI) return;
|
|
163
175
|
if ((args ?? "").trim().toLowerCase() === "quota") {
|
|
164
|
-
// The one networked
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
176
|
+
// The one networked path in this package, and only when asked for.
|
|
177
|
+
// Providers with no key are skipped entirely rather than reported as
|
|
178
|
+
// broken: an unconfigured provider is not a failure.
|
|
179
|
+
const configured: Array<{ provider: (typeof QUOTA_PROVIDERS)[number]; key: string }> = [];
|
|
180
|
+
for (const provider of QUOTA_PROVIDERS) {
|
|
181
|
+
const key = await providerKey(ctx, provider.id);
|
|
182
|
+
if (key) configured.push({ provider, key });
|
|
183
|
+
}
|
|
184
|
+
if (configured.length === 0) {
|
|
185
|
+
ctx.ui.notify(quotaReport([]), "info");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
ctx.ui.notify(`Checking quota for ${configured.map((c) => c.provider.displayName).join(", ")}…`, "info");
|
|
189
|
+
const results: QuotaResult[] = [];
|
|
190
|
+
for (const { provider, key } of configured) {
|
|
191
|
+
results.push(await fetchQuota(provider, key));
|
|
192
|
+
}
|
|
193
|
+
ctx.ui.notify(redact(quotaReport(results)), results.every((r) => r.ok) ? "info" : "warning");
|
|
168
194
|
return;
|
|
169
195
|
}
|
|
170
196
|
ctx.ui.notify(dashboard(ctx), "info");
|
package/package.json
CHANGED
package/skills/usage/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: usage
|
|
3
|
-
description: Use when cost or token consumption matters to a decision - before expensive operations (large reads, many subagents, long loops) or when the user asks about spend
|
|
3
|
+
description: Use when cost or token consumption matters to a decision - before expensive operations (large reads, many subagents, long loops) or when the user asks about spend
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Usage awareness
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one networked path in this package, made boring on purpose.
|
|
3
|
+
*
|
|
4
|
+
* A quota call carries the user's provider key in an Authorization header, so
|
|
5
|
+
* the request has to be pinned down rather than merely aimed at the right URL:
|
|
6
|
+
*
|
|
7
|
+
* - HTTPS only, and the host must be on the caller's allowlist. A typo or a
|
|
8
|
+
* future edit cannot point a credentialed request somewhere new.
|
|
9
|
+
* - Redirects are refused outright (`redirect: "error"`). Following one means
|
|
10
|
+
* a provider — or anything that can answer as one — chooses where the next
|
|
11
|
+
* request goes, with the header already attached.
|
|
12
|
+
* - A non-2xx body is never read. Error bodies echo request details back, and
|
|
13
|
+
* an echoed Authorization header pasted into a notification is exactly the
|
|
14
|
+
* leak this package must not cause. The status alone is the message.
|
|
15
|
+
* - Everything is classified into a small set of reasons, so the caller can
|
|
16
|
+
* say something useful without carrying provider text around.
|
|
17
|
+
*
|
|
18
|
+
* `fetchImpl` is injectable so tests never touch the network.
|
|
19
|
+
*
|
|
20
|
+
* (Structure and the no-body-on-error rule are from imdlan/pi-usage.)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type HttpFailureKind =
|
|
24
|
+
| "unsafe-url"
|
|
25
|
+
| "not-allowed"
|
|
26
|
+
| "auth"
|
|
27
|
+
| "forbidden"
|
|
28
|
+
| "rate-limited"
|
|
29
|
+
| "server"
|
|
30
|
+
| "http"
|
|
31
|
+
| "timeout"
|
|
32
|
+
| "network"
|
|
33
|
+
| "invalid-json";
|
|
34
|
+
|
|
35
|
+
export interface HttpFailure {
|
|
36
|
+
ok: false;
|
|
37
|
+
kind: HttpFailureKind;
|
|
38
|
+
reason: string;
|
|
39
|
+
status?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface HttpSuccess<T> {
|
|
43
|
+
ok: true;
|
|
44
|
+
status: number;
|
|
45
|
+
data: T;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type HttpResult<T> = HttpSuccess<T> | HttpFailure;
|
|
49
|
+
|
|
50
|
+
export interface ControlledGetOptions {
|
|
51
|
+
url: string;
|
|
52
|
+
headers?: Record<string, string>;
|
|
53
|
+
timeoutMs: number;
|
|
54
|
+
allowlist: readonly string[];
|
|
55
|
+
fetchImpl?: typeof fetch;
|
|
56
|
+
signal?: AbortSignal;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const TIMEOUT_SENTINEL = "pify-usage-timeout";
|
|
60
|
+
|
|
61
|
+
function fail(kind: HttpFailureKind, reason: string, status?: number): HttpFailure {
|
|
62
|
+
return { ok: false, kind, reason, ...(status === undefined ? {} : { status }) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Reject anything that is not an allowlisted HTTPS host before dialling. */
|
|
66
|
+
export function checkUrl(url: string, allowlist: readonly string[]): HttpFailure | null {
|
|
67
|
+
let parsed: URL;
|
|
68
|
+
try {
|
|
69
|
+
parsed = new URL(url);
|
|
70
|
+
} catch {
|
|
71
|
+
return fail("unsafe-url", "invalid url");
|
|
72
|
+
}
|
|
73
|
+
if (parsed.protocol !== "https:") return fail("unsafe-url", "refused a non-HTTPS url");
|
|
74
|
+
if (!allowlist.includes(parsed.hostname)) {
|
|
75
|
+
return fail("not-allowed", `host ${parsed.hostname} is not on the allowlist`);
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function classifyStatus(status: number): HttpFailure {
|
|
81
|
+
if (status === 401) return fail("auth", "the provider rejected the key", status);
|
|
82
|
+
if (status === 403) return fail("forbidden", "the key is not allowed to read usage", status);
|
|
83
|
+
if (status === 429) return fail("rate-limited", "rate limited by the provider", status);
|
|
84
|
+
if (status >= 500) return fail("server", "the provider is having trouble", status);
|
|
85
|
+
return fail("http", `HTTP ${status}`, status);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** GET JSON from an allowlisted host. Never throws; failure is a value. */
|
|
89
|
+
export async function controlledGetJson<T = unknown>(opts: ControlledGetOptions): Promise<HttpResult<T>> {
|
|
90
|
+
const urlProblem = checkUrl(opts.url, opts.allowlist);
|
|
91
|
+
if (urlProblem) return urlProblem;
|
|
92
|
+
|
|
93
|
+
const controller = new AbortController();
|
|
94
|
+
const timer = setTimeout(() => controller.abort(new Error(TIMEOUT_SENTINEL)), opts.timeoutMs);
|
|
95
|
+
if (opts.signal) {
|
|
96
|
+
if (opts.signal.aborted) controller.abort();
|
|
97
|
+
else opts.signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
98
|
+
}
|
|
99
|
+
const doFetch = opts.fetchImpl ?? (globalThis.fetch as typeof fetch);
|
|
100
|
+
|
|
101
|
+
let response: Response;
|
|
102
|
+
try {
|
|
103
|
+
response = await doFetch(opts.url, {
|
|
104
|
+
method: "GET",
|
|
105
|
+
headers: opts.headers ?? {},
|
|
106
|
+
signal: controller.signal,
|
|
107
|
+
redirect: "error",
|
|
108
|
+
});
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (controller.signal.aborted) {
|
|
111
|
+
const timedOut = String(controller.signal.reason ?? "").includes(TIMEOUT_SENTINEL);
|
|
112
|
+
return timedOut ? fail("timeout", "the provider did not answer in time") : fail("network", "request aborted");
|
|
113
|
+
}
|
|
114
|
+
const message = String((err as { message?: unknown })?.message ?? err).toLowerCase();
|
|
115
|
+
if (message.includes("redirect")) return fail("unsafe-url", "the provider tried to redirect us");
|
|
116
|
+
if (message.includes("timeout") || message.includes("timed out")) {
|
|
117
|
+
return fail("timeout", "the provider did not answer in time");
|
|
118
|
+
}
|
|
119
|
+
// The raw error is deliberately dropped: it can carry the request, and the
|
|
120
|
+
// request carries the key.
|
|
121
|
+
return fail("network", "could not reach the provider");
|
|
122
|
+
} finally {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (response.status >= 200 && response.status < 300) {
|
|
127
|
+
try {
|
|
128
|
+
return { ok: true, status: response.status, data: (await response.json()) as T };
|
|
129
|
+
} catch {
|
|
130
|
+
return fail("invalid-json", "the provider sent something that is not JSON", response.status);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return classifyStatus(response.status);
|
|
134
|
+
}
|
package/src/quota.ts
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Provider quota
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* that is slow or down shows as unavailable next to the
|
|
2
|
+
* Provider quota. Everything else in this package is computed from local
|
|
3
|
+
* files; this is the one place that talks to a network, so it is opt-in per
|
|
4
|
+
* call, short-timeout, pinned to allowlisted HTTPS hosts, and never blocks the
|
|
5
|
+
* dashboard: a provider that is slow or down shows as unavailable next to the
|
|
6
|
+
* local numbers.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Documented endpoints only. OpenRouter's `/api/v1/key` and DeepSeek's
|
|
9
|
+
* `/user/balance` are both published APIs that report a real balance. The
|
|
10
|
+
* subscription-quota endpoints some plugins use for OpenAI, Anthropic and
|
|
11
|
+
* Gemini are undocumented private APIs reverse-engineered from vendor CLIs;
|
|
12
|
+
* they break without notice and were never offered to third parties, so this
|
|
13
|
+
* package does not call them. (That line is imdlan/pi-usage's, and it is a
|
|
14
|
+
* better reason than the maintenance cost this package cited before.)
|
|
11
15
|
*/
|
|
12
16
|
|
|
17
|
+
import { controlledGetJson, type HttpResult } from "./http.ts";
|
|
13
18
|
import { finite, isRecord } from "./types.ts";
|
|
14
19
|
|
|
15
20
|
export interface QuotaInfo {
|
|
16
21
|
provider: string;
|
|
17
|
-
/** Spend on this key, in
|
|
22
|
+
/** Spend on this key, in the provider's currency, as it reports it. */
|
|
18
23
|
used: number | null;
|
|
19
24
|
/** Hard credit limit, when the key has one. */
|
|
20
25
|
limit: number | null;
|
|
@@ -25,6 +30,8 @@ export interface QuotaInfo {
|
|
|
25
30
|
monthly: number | null;
|
|
26
31
|
label: string | null;
|
|
27
32
|
freeTier: boolean | null;
|
|
33
|
+
/** Currency for the amounts above; USD unless the provider says otherwise. */
|
|
34
|
+
currency: string;
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
export type QuotaResult =
|
|
@@ -37,6 +44,29 @@ function num(value: unknown): number | null {
|
|
|
37
44
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
38
45
|
}
|
|
39
46
|
|
|
47
|
+
/** "110.00" — DeepSeek reports money as strings. */
|
|
48
|
+
function numeric(value: unknown): number | null {
|
|
49
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
50
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
51
|
+
const parsed = Number.parseFloat(value);
|
|
52
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function emptyQuota(provider: string): QuotaInfo {
|
|
56
|
+
return {
|
|
57
|
+
provider,
|
|
58
|
+
used: null,
|
|
59
|
+
limit: null,
|
|
60
|
+
remaining: null,
|
|
61
|
+
daily: null,
|
|
62
|
+
weekly: null,
|
|
63
|
+
monthly: null,
|
|
64
|
+
label: null,
|
|
65
|
+
freeTier: null,
|
|
66
|
+
currency: "USD",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
40
70
|
/**
|
|
41
71
|
* Shape the /api/v1/key payload. OpenRouter reports `limit: null` for keys
|
|
42
72
|
* with no cap, so "no limit" and "limit of zero" must not collapse together.
|
|
@@ -53,7 +83,7 @@ export function parseOpenRouterKey(payload: unknown): QuotaInfo | null {
|
|
|
53
83
|
const remaining = num(data.limit_remaining) ?? (limit !== null && used !== null ? limit - used : null);
|
|
54
84
|
|
|
55
85
|
return {
|
|
56
|
-
|
|
86
|
+
...emptyQuota("openrouter"),
|
|
57
87
|
used: used === null ? null : finite(used),
|
|
58
88
|
limit,
|
|
59
89
|
remaining,
|
|
@@ -65,60 +95,134 @@ export function parseOpenRouterKey(payload: unknown): QuotaInfo | null {
|
|
|
65
95
|
};
|
|
66
96
|
}
|
|
67
97
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
98
|
+
/**
|
|
99
|
+
* DeepSeek's documented `/user/balance`: a list of per-currency balances. The
|
|
100
|
+
* first available one is reported; a key with no available balance is still a
|
|
101
|
+
* successful answer, showing zero rather than an error.
|
|
102
|
+
*/
|
|
103
|
+
export function parseDeepSeekBalance(payload: unknown): QuotaInfo | null {
|
|
104
|
+
if (!isRecord(payload)) return null;
|
|
105
|
+
const infos = payload.balance_infos;
|
|
106
|
+
if (!Array.isArray(infos)) return null;
|
|
107
|
+
|
|
108
|
+
const first = infos.find((entry) => isRecord(entry) && numeric(entry.total_balance) !== null);
|
|
109
|
+
if (!isRecord(first)) {
|
|
110
|
+
return { ...emptyQuota("deepseek"), remaining: 0 };
|
|
111
|
+
}
|
|
112
|
+
const total = numeric(first.total_balance);
|
|
113
|
+
const granted = numeric(first.granted_balance);
|
|
114
|
+
const topped = numeric(first.topped_up_balance);
|
|
115
|
+
const parts = [
|
|
116
|
+
granted === null ? null : `granted ${granted}`,
|
|
117
|
+
topped === null ? null : `topped up ${topped}`,
|
|
118
|
+
].filter((part): part is string => part !== null);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
...emptyQuota("deepseek"),
|
|
122
|
+
remaining: total,
|
|
123
|
+
currency: typeof first.currency === "string" ? first.currency : "USD",
|
|
124
|
+
label: parts.length > 0 ? parts.join(" · ") : null,
|
|
125
|
+
freeTier: payload.is_available === false ? null : null,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
73
128
|
|
|
74
|
-
|
|
75
|
-
|
|
129
|
+
export interface QuotaProvider {
|
|
130
|
+
/** pi's provider id, which is also how the key is looked up. */
|
|
131
|
+
id: string;
|
|
132
|
+
displayName: string;
|
|
133
|
+
url: string;
|
|
134
|
+
allowlist: readonly string[];
|
|
135
|
+
parse(payload: unknown): QuotaInfo | null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const QUOTA_PROVIDERS: readonly QuotaProvider[] = [
|
|
139
|
+
{
|
|
140
|
+
id: "openrouter",
|
|
141
|
+
displayName: "OpenRouter",
|
|
142
|
+
url: "https://openrouter.ai/api/v1/key",
|
|
143
|
+
allowlist: ["openrouter.ai"],
|
|
144
|
+
parse: parseOpenRouterKey,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: "deepseek",
|
|
148
|
+
displayName: "DeepSeek",
|
|
149
|
+
url: "https://api.deepseek.com/user/balance",
|
|
150
|
+
allowlist: ["api.deepseek.com"],
|
|
151
|
+
parse: parseDeepSeekBalance,
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
export function findProvider(id: string): QuotaProvider | undefined {
|
|
156
|
+
return QUOTA_PROVIDERS.find((provider) => provider.id === id.trim().toLowerCase());
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Query one provider. Never throws — failure is a result, and its reason is
|
|
161
|
+
* this package's own wording, never the provider's response text.
|
|
162
|
+
*/
|
|
163
|
+
export async function fetchQuota(
|
|
164
|
+
provider: QuotaProvider,
|
|
76
165
|
apiKey: string,
|
|
77
|
-
|
|
166
|
+
fetchImpl?: typeof fetch,
|
|
78
167
|
timeoutMs = QUOTA_TIMEOUT_MS,
|
|
79
168
|
): Promise<QuotaResult> {
|
|
80
|
-
if (!apiKey.trim()) return { ok: false, provider:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
ok: false,
|
|
95
|
-
provider: "openrouter",
|
|
96
|
-
reason: err instanceof Error ? err.message : String(err),
|
|
97
|
-
};
|
|
98
|
-
}
|
|
169
|
+
if (!apiKey.trim()) return { ok: false, provider: provider.id, reason: "no API key configured" };
|
|
170
|
+
|
|
171
|
+
const result: HttpResult<unknown> = await controlledGetJson({
|
|
172
|
+
url: provider.url,
|
|
173
|
+
headers: { Authorization: `Bearer ${apiKey.trim()}`, Accept: "application/json" },
|
|
174
|
+
timeoutMs,
|
|
175
|
+
allowlist: provider.allowlist,
|
|
176
|
+
fetchImpl,
|
|
177
|
+
});
|
|
178
|
+
if (!result.ok) return { ok: false, provider: provider.id, reason: result.reason };
|
|
179
|
+
|
|
180
|
+
const quota = provider.parse(result.data);
|
|
181
|
+
if (!quota) return { ok: false, provider: provider.id, reason: "the response did not match the documented shape" };
|
|
182
|
+
return { ok: true, quota };
|
|
99
183
|
}
|
|
100
184
|
|
|
101
|
-
function money(value: number | null): string {
|
|
185
|
+
function money(value: number | null, currency = "USD"): string {
|
|
102
186
|
if (value === null) return "—";
|
|
103
|
-
|
|
104
|
-
if (
|
|
105
|
-
|
|
187
|
+
const symbol = currency === "USD" ? "$" : `${currency} `;
|
|
188
|
+
if (value === 0) return `${symbol}0`;
|
|
189
|
+
if (Math.abs(value) < 0.01) return `<${symbol}0.01`;
|
|
190
|
+
return `${symbol}${value.toFixed(2)}`;
|
|
106
191
|
}
|
|
107
192
|
|
|
108
193
|
export function quotaBlock(result: QuotaResult): string {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
194
|
+
const id = result.ok ? result.quota.provider : result.provider;
|
|
195
|
+
const name = findProvider(id)?.displayName ?? id;
|
|
196
|
+
if (!result.ok) return `Quota (${name})\n unavailable — ${result.reason}`;
|
|
197
|
+
|
|
112
198
|
const q = result.quota;
|
|
113
|
-
const lines = [`Quota (${
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
)
|
|
199
|
+
const lines = [`Quota (${name}${q.label ? ` · ${q.label}` : ""})`];
|
|
200
|
+
if (q.used !== null || q.limit !== null) {
|
|
201
|
+
lines.push(
|
|
202
|
+
q.limit === null
|
|
203
|
+
? ` spent ${money(q.used, q.currency)} (no credit limit on this key)`
|
|
204
|
+
: ` spent ${money(q.used, q.currency)} of ${money(q.limit, q.currency)} · ${money(q.remaining, q.currency)} left`,
|
|
205
|
+
);
|
|
206
|
+
} else if (q.remaining !== null) {
|
|
207
|
+
lines.push(` balance ${money(q.remaining, q.currency)}`);
|
|
208
|
+
}
|
|
119
209
|
if (q.daily !== null || q.weekly !== null || q.monthly !== null) {
|
|
120
|
-
lines.push(
|
|
210
|
+
lines.push(
|
|
211
|
+
` window day ${money(q.daily, q.currency)} · week ${money(q.weekly, q.currency)} · month ${money(q.monthly, q.currency)}`,
|
|
212
|
+
);
|
|
121
213
|
}
|
|
122
214
|
if (q.freeTier) lines.push(" tier free");
|
|
123
215
|
return lines.join("\n");
|
|
124
216
|
}
|
|
217
|
+
|
|
218
|
+
/** What `/usage quota` prints: every provider that has a key, and nothing else. */
|
|
219
|
+
export function quotaReport(results: QuotaResult[]): string {
|
|
220
|
+
if (results.length === 0) {
|
|
221
|
+
return [
|
|
222
|
+
"No provider with a quota endpoint is configured.",
|
|
223
|
+
`Supported: ${QUOTA_PROVIDERS.map((p) => p.displayName).join(", ")}.`,
|
|
224
|
+
"Only documented endpoints are used — OpenAI, Anthropic and Gemini publish none for subscription quota.",
|
|
225
|
+
].join("\n");
|
|
226
|
+
}
|
|
227
|
+
return results.map(quotaBlock).join("\n\n");
|
|
228
|
+
}
|
package/src/redact.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defence in depth for anything this package prints.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here is supposed to see a secret: keys come from pi's own auth
|
|
5
|
+
* resolution and go straight into a header, error bodies are never read, and
|
|
6
|
+
* raw exception text is dropped rather than shown. This scrubber exists for
|
|
7
|
+
* the case where one of those is wrong — a key in a message costs the user a
|
|
8
|
+
* rotation, and the cost of running a regex over a notification is nothing.
|
|
9
|
+
*
|
|
10
|
+
* The patterns describe what to REMOVE. They are never used to find or store
|
|
11
|
+
* a secret. (Idea from imdlan/pi-usage.)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const REDACTED = "[redacted]";
|
|
15
|
+
|
|
16
|
+
const RULES: ReadonlyArray<{ re: RegExp; replacement: string }> = [
|
|
17
|
+
// Provider key shapes, longest-prefix first.
|
|
18
|
+
{ re: /\bsk-or-v1-[A-Za-z0-9]{16,}/g, replacement: REDACTED },
|
|
19
|
+
{ re: /\bsk-ant-[A-Za-z0-9_-]{16,}/g, replacement: REDACTED },
|
|
20
|
+
{ re: /\bsk-[A-Za-z0-9_-]{16,}/g, replacement: REDACTED },
|
|
21
|
+
{ re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g, replacement: REDACTED },
|
|
22
|
+
{ re: /\bAIza[0-9A-Za-z_-]{20,}/g, replacement: REDACTED },
|
|
23
|
+
{ re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: REDACTED },
|
|
24
|
+
{ re: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, replacement: REDACTED },
|
|
25
|
+
// Headers and assignments that name a secret, whatever the value looks like.
|
|
26
|
+
// To end of line, not to the first space: "Bearer <token>" is two words and
|
|
27
|
+
// stopping early leaves exactly the half that matters.
|
|
28
|
+
{ re: /\b(?:proxy-)?authorization\s*[:=]\s*[^\r\n;,]+/gi, replacement: `authorization: ${REDACTED}` },
|
|
29
|
+
{
|
|
30
|
+
re: /\b(api[_-]?key|access[_-]?token|auth[_-]?token|secret|password)\b\s*[:=]\s*["']?[^\s"',;]+/gi,
|
|
31
|
+
replacement: `$1=${REDACTED}`,
|
|
32
|
+
},
|
|
33
|
+
// A URL's query string can carry a token; the path is enough to identify it.
|
|
34
|
+
{ re: /(https?:\/\/[^\s?]+)\?[^\s]*/gi, replacement: "$1?[redacted]" },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export function redact(text: string): string {
|
|
38
|
+
let out = text ?? "";
|
|
39
|
+
for (const { re, replacement } of RULES) out = out.replace(re, replacement);
|
|
40
|
+
return out;
|
|
41
|
+
}
|