@pify/usage 0.4.0 → 0.5.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 +20 -6
- package/extensions/usage.ts +37 -11
- package/package.json +1 -1
- package/src/http.ts +134 -0
- package/src/quota.ts +155 -51
- package/src/redact.ts +41 -0
package/README.md
CHANGED
|
@@ -45,19 +45,33 @@ Context window: 22.6k of 200.0k used (11%)
|
|
|
45
45
|
- **Per-project spend** (v0.2): pi stores sessions one directory per project, so the dashboard can show where the money actually went — the top 5 projects by cost, all time.
|
|
46
46
|
- **`usage_status` tool**: the agent can check session + today totals before committing to expensive work (subagent fan-outs, large reads).
|
|
47
47
|
|
|
48
|
-
## `/usage quota` (v0.
|
|
48
|
+
## `/usage quota` (v0.5)
|
|
49
49
|
|
|
50
50
|
The one command in this package that touches the network, and only when you run it:
|
|
51
51
|
|
|
52
52
|
```
|
|
53
|
-
Quota (
|
|
54
|
-
spent $0.
|
|
55
|
-
window day $0.
|
|
53
|
+
Quota (OpenRouter · sk-or-v1-abc...xyz)
|
|
54
|
+
spent $0.33 (no credit limit on this key)
|
|
55
|
+
window day $0.01 · week $0.33 · month $0.33
|
|
56
|
+
|
|
57
|
+
Quota (DeepSeek · granted 10 · topped up 100)
|
|
58
|
+
balance CNY 110.00
|
|
56
59
|
```
|
|
57
60
|
|
|
58
|
-
|
|
61
|
+
**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 some plugins use for OpenAI, Anthropic and Gemini 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. Providers you have not configured are simply not shown; they are not failures.
|
|
62
|
+
|
|
63
|
+
**A credentialed request is pinned down** (v0.5), because it carries your provider key:
|
|
64
|
+
|
|
65
|
+
- HTTPS only, and the host must be on that provider's allowlist.
|
|
66
|
+
- Redirects are refused outright. Following one lets whatever answered choose where the next request goes, with the header already attached.
|
|
67
|
+
- 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.
|
|
68
|
+
- Raw exception text is dropped rather than shown, and everything printed passes a redactor as a last line of defence.
|
|
69
|
+
|
|
70
|
+
**The key comes from pi** (v0.5): resolved through `modelRegistry`, not by reading `auth.json`. pi owns credential storage — env precedence, OAuth, whatever it grows next — and parsing that file here meant handling secrets this package has no business touching, with a stale copy of pi's rules. Only the provider's own masked label is ever printed.
|
|
71
|
+
|
|
72
|
+
An 8-second timeout per provider, and any failure renders as `unavailable — the provider rejected the key` rather than throwing.
|
|
59
73
|
|
|
60
|
-
|
|
74
|
+
The hardening, the multi-provider shape, and the documented-APIs-only stance are from [`@imdlan/pi-usage`](https://github.com/imdlan/pi-usage), which supports Z.ai as well.
|
|
61
75
|
|
|
62
76
|
## License
|
|
63
77
|
|
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/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
|
+
}
|