@pify/usage 0.3.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 +34 -6
- package/extensions/usage.ts +87 -11
- package/package.json +3 -3
- package/src/context.ts +193 -0
- package/src/http.ts +134 -0
- package/src/quota.ts +155 -51
- package/src/redact.ts +41 -0
package/README.md
CHANGED
|
@@ -28,22 +28,50 @@ By project (all time)
|
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
- **History done right** (tmustier's lessons): counts every usage-bearing entry in pi's session JSONL — assistant turns plus the tool-result/compaction usage pi 0.81+ persists; negative/NaN fields clamp to zero; days are your local calendar days; a per-file mtime cache keeps repeat scans instant.
|
|
31
|
+
- **Where the window went** (v0.4): `/usage context` breaks the context window into system prompt, context files, skills, tool definitions, tool results, and conversation — so "why am I at 60%?" has an answer that is usually "one `read` of a 4,000-line file", not a mystery.
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
Context window: 22.6k of 200.0k used (11%)
|
|
35
|
+
System prompt ···························· <1% 11
|
|
36
|
+
Context files ···························· 1% 3.0k
|
|
37
|
+
Tool definitions ···························· <1% 371
|
|
38
|
+
Tool results ██·························· 8% 16.8k
|
|
39
|
+
Conversation ···························· 1% 2.4k
|
|
40
|
+
Free space █████████████████████████··· 89% 177.4k
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Computed 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. 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 we can attribute, the difference is shown as **Other** rather than dropped. (The idea is from [`pi-cc-extensions`](https://github.com/minuque/pi-cc-extensions)' `/context`.)
|
|
44
|
+
|
|
31
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.
|
|
32
46
|
- **`usage_status` tool**: the agent can check session + today totals before committing to expensive work (subagent fan-outs, large reads).
|
|
33
47
|
|
|
34
|
-
## `/usage quota` (v0.
|
|
48
|
+
## `/usage quota` (v0.5)
|
|
35
49
|
|
|
36
50
|
The one command in this package that touches the network, and only when you run it:
|
|
37
51
|
|
|
38
52
|
```
|
|
39
|
-
Quota (
|
|
40
|
-
spent $0.
|
|
41
|
-
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
|
|
42
59
|
```
|
|
43
60
|
|
|
44
|
-
|
|
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.
|
|
45
73
|
|
|
46
|
-
|
|
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.
|
|
47
75
|
|
|
48
76
|
## License
|
|
49
77
|
|
package/extensions/usage.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* is the one that reports a real balance rather than an opaque window.
|
|
19
19
|
*/
|
|
20
20
|
import {
|
|
21
|
+
formatSkillsForPrompt,
|
|
21
22
|
getAgentDir,
|
|
22
23
|
type ExtensionAPI,
|
|
23
24
|
type ExtensionContext,
|
|
@@ -27,8 +28,10 @@ import { join } from "node:path";
|
|
|
27
28
|
import { readFileSync } from "node:fs";
|
|
28
29
|
|
|
29
30
|
import { addRecord, aggregate, recordFromEntry, windowTotals } from "../src/aggregate.ts";
|
|
31
|
+
import { buildBreakdown, formatBreakdown } from "../src/context.ts";
|
|
30
32
|
import { footerText, formatCost, formatTokens, historyBlock, sessionBlock } from "../src/format.ts";
|
|
31
|
-
import {
|
|
33
|
+
import { QUOTA_PROVIDERS, fetchQuota, quotaReport, type QuotaResult } from "../src/quota.ts";
|
|
34
|
+
import { redact } from "../src/redact.ts";
|
|
32
35
|
import { scanSessions } from "../src/sessions.ts";
|
|
33
36
|
import { emptyTotals, isRecord, type UsageTotals } from "../src/types.ts";
|
|
34
37
|
|
|
@@ -95,15 +98,74 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
95
98
|
* The key pi itself uses, read from the same auth.json — no second place to
|
|
96
99
|
* configure credentials, and no key is ever printed.
|
|
97
100
|
*/
|
|
98
|
-
|
|
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
|
+
};
|
|
112
|
+
try {
|
|
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;
|
|
117
|
+
} catch {
|
|
118
|
+
// an unconfigured provider is not an error here
|
|
119
|
+
}
|
|
120
|
+
return "";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Where the context window went, computed from what pi already holds:
|
|
125
|
+
* the assembled system prompt, the context files and skills embedded in
|
|
126
|
+
* it, the enabled tool definitions, and the entries that would be sent.
|
|
127
|
+
* No network, no model call — same rule as the rest of the package.
|
|
128
|
+
*/
|
|
129
|
+
function contextBreakdown(ctx: UiContext): string {
|
|
130
|
+
const host = ctx as unknown as {
|
|
131
|
+
getSystemPrompt?: () => string;
|
|
132
|
+
getSystemPromptOptions?: () => {
|
|
133
|
+
contextFiles?: Array<{ path?: string; content?: string }>;
|
|
134
|
+
skills?: unknown[];
|
|
135
|
+
selectedTools?: string[];
|
|
136
|
+
};
|
|
137
|
+
getContextUsage?: () => { contextWindow?: number; used?: number; total?: number } | undefined;
|
|
138
|
+
sessionManager?: { buildContextEntries?: () => unknown[]; getBranch?: () => unknown[] };
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const systemPrompt = host.getSystemPrompt?.() ?? "";
|
|
142
|
+
const options = host.getSystemPromptOptions?.() ?? {};
|
|
143
|
+
const selected = new Set(options.selectedTools ?? []);
|
|
144
|
+
const allTools = (pi as unknown as { getAllTools?: () => Array<{ name?: string }> }).getAllTools?.() ?? [];
|
|
145
|
+
const tools = selected.size > 0 ? allTools.filter((t) => selected.has(t.name ?? "")) : allTools;
|
|
146
|
+
|
|
147
|
+
let skillsText = "";
|
|
99
148
|
try {
|
|
100
|
-
|
|
101
|
-
const entry = auth[provider];
|
|
102
|
-
if (isRecord(entry) && typeof entry.key === "string") return entry.key;
|
|
149
|
+
skillsText = formatSkillsForPrompt((options.skills ?? []) as never).trim();
|
|
103
150
|
} catch {
|
|
104
|
-
//
|
|
151
|
+
// A pi version that formats skills differently just reports 0 here.
|
|
105
152
|
}
|
|
106
|
-
|
|
153
|
+
|
|
154
|
+
const entries = host.sessionManager?.buildContextEntries?.() ?? host.sessionManager?.getBranch?.() ?? [];
|
|
155
|
+
const usage = host.getContextUsage?.();
|
|
156
|
+
const contextWindow =
|
|
157
|
+
usage?.contextWindow ?? (ctx.model as { contextWindow?: number } | null)?.contextWindow ?? 0;
|
|
158
|
+
|
|
159
|
+
const breakdown = buildBreakdown({
|
|
160
|
+
systemPrompt,
|
|
161
|
+
contextFiles: options.contextFiles ?? [],
|
|
162
|
+
skillsText,
|
|
163
|
+
tools,
|
|
164
|
+
entries,
|
|
165
|
+
contextWindow,
|
|
166
|
+
});
|
|
167
|
+
const reported = typeof usage?.used === "number" ? usage.used : lastPromptTokens || null;
|
|
168
|
+
return formatBreakdown(breakdown, reported);
|
|
107
169
|
}
|
|
108
170
|
|
|
109
171
|
pi.registerCommand("usage", {
|
|
@@ -111,10 +173,24 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
111
173
|
handler: async (args, ctx) => {
|
|
112
174
|
if (!ctx.hasUI) return;
|
|
113
175
|
if ((args ?? "").trim().toLowerCase() === "quota") {
|
|
114
|
-
// The one networked
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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");
|
|
118
194
|
return;
|
|
119
195
|
}
|
|
120
196
|
ctx.ui.notify(dashboard(ctx), "info");
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pify/usage",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Token and cost reporting for pi sessions: live footer,
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Token and cost reporting for pi sessions: live footer, local-history dashboard with per-project costs, opt-in provider quota",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"pi-extension",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
}
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
61
|
+
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
62
62
|
"@types/node": "^22.10.2",
|
|
63
63
|
"typebox": "^1.1.38",
|
|
64
64
|
"typescript": "^5.7.2"
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the context window actually went (the idea, and the accounting
|
|
3
|
+
* discipline, are from minuque/pi-cc-extensions' /context).
|
|
4
|
+
*
|
|
5
|
+
* The rest of this package answers "what have I spent". This answers the
|
|
6
|
+
* other question you have at 60% context: "spent on WHAT". Both are computed
|
|
7
|
+
* from what pi already has in memory — no network, no model call — so the
|
|
8
|
+
* numbers are estimates (chars/4, the same estimate pi uses for compaction),
|
|
9
|
+
* not provider billing.
|
|
10
|
+
*
|
|
11
|
+
* Two rules keep the estimate honest:
|
|
12
|
+
* - context files and skills are counted only when their text is actually
|
|
13
|
+
* embedded in the assembled system prompt, so a file that was loaded but
|
|
14
|
+
* not injected does not appear twice;
|
|
15
|
+
* - the system-prompt row is the remainder after those are subtracted, so
|
|
16
|
+
* the parts sum to the whole instead of overlapping.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface ContextPart {
|
|
20
|
+
label: string;
|
|
21
|
+
tokens: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ContextBreakdown {
|
|
25
|
+
parts: ContextPart[];
|
|
26
|
+
/** Sum of every attributed part. */
|
|
27
|
+
attributed: number;
|
|
28
|
+
contextWindow: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** pi's own compaction estimate: four characters per token. */
|
|
32
|
+
export function estimateTextTokens(value: unknown): number {
|
|
33
|
+
if (value === null || value === undefined || value === "") return 0;
|
|
34
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
35
|
+
return Math.max(0, Math.ceil(text.length / 4));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Count a chunk only when the assembled prompt really contains it. */
|
|
39
|
+
export function embeddedTokens(systemPrompt: string, chunk: string): number {
|
|
40
|
+
if (!chunk || !systemPrompt.includes(chunk)) return 0;
|
|
41
|
+
return estimateTextTokens(chunk);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface BreakdownInput {
|
|
45
|
+
systemPrompt: string;
|
|
46
|
+
/** Context files (AGENTS.md, CLAUDE.md, …) pi loaded for the prompt. */
|
|
47
|
+
contextFiles: Array<{ path?: string; content?: string }>;
|
|
48
|
+
/** Skills text as pi formats it into the prompt. */
|
|
49
|
+
skillsText: string;
|
|
50
|
+
/** Definitions of the tools actually enabled this turn. */
|
|
51
|
+
tools: Array<{ name?: string; description?: string; parameters?: unknown }>;
|
|
52
|
+
/** Entries pi would send as conversation this turn. */
|
|
53
|
+
entries: unknown[];
|
|
54
|
+
contextWindow: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
58
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Split the conversation into what the agent said and what tools returned.
|
|
63
|
+
* Tool results are the part that grows without anyone deciding it should,
|
|
64
|
+
* which is exactly why they deserve their own row.
|
|
65
|
+
*/
|
|
66
|
+
function foldEntries(entries: unknown[]): { conversation: number; toolResults: number } {
|
|
67
|
+
let conversation = 0;
|
|
68
|
+
let toolResults = 0;
|
|
69
|
+
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
if (!isRecord(entry)) continue;
|
|
72
|
+
const message = isRecord(entry.message) ? entry.message : entry;
|
|
73
|
+
const role = typeof message.role === "string" ? message.role : null;
|
|
74
|
+
|
|
75
|
+
if (role === "toolResult" || role === "bashExecution") {
|
|
76
|
+
toolResults += estimateTextTokens(message.content ?? message.output ?? message);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (role === "assistant" && Array.isArray(message.content)) {
|
|
80
|
+
for (const block of message.content as Array<Record<string, unknown>>) {
|
|
81
|
+
if (!isRecord(block)) continue;
|
|
82
|
+
if (block.type === "toolCall") {
|
|
83
|
+
conversation += estimateTextTokens(block.name) + estimateTextTokens(block.arguments);
|
|
84
|
+
} else if (block.type === "text") {
|
|
85
|
+
conversation += estimateTextTokens(block.text);
|
|
86
|
+
} else if (block.type === "thinking") {
|
|
87
|
+
conversation += estimateTextTokens(block.thinking);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (typeof entry.summary === "string") {
|
|
93
|
+
conversation += estimateTextTokens(entry.summary);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (typeof entry.content === "string" || Array.isArray(entry.content)) {
|
|
97
|
+
conversation += estimateTextTokens(entry.content);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (role) conversation += estimateTextTokens(message.content ?? message);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { conversation, toolResults };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function buildBreakdown(input: BreakdownInput): ContextBreakdown {
|
|
107
|
+
const memory = input.contextFiles.reduce(
|
|
108
|
+
(sum, file) => sum + embeddedTokens(input.systemPrompt, file.content ?? ""),
|
|
109
|
+
0,
|
|
110
|
+
);
|
|
111
|
+
const skills = embeddedTokens(input.systemPrompt, input.skillsText.trim());
|
|
112
|
+
const tools = input.tools.reduce(
|
|
113
|
+
(sum, tool) =>
|
|
114
|
+
sum +
|
|
115
|
+
estimateTextTokens({
|
|
116
|
+
name: tool.name,
|
|
117
|
+
description: tool.description,
|
|
118
|
+
parameters: tool.parameters,
|
|
119
|
+
}),
|
|
120
|
+
0,
|
|
121
|
+
);
|
|
122
|
+
const { conversation, toolResults } = foldEntries(input.entries);
|
|
123
|
+
// Memory and skills live inside the prompt; subtract so rows do not overlap.
|
|
124
|
+
const system = Math.max(0, estimateTextTokens(input.systemPrompt) - memory - skills);
|
|
125
|
+
|
|
126
|
+
const parts: ContextPart[] = [
|
|
127
|
+
{ label: "System prompt", tokens: system },
|
|
128
|
+
{ label: "Context files", tokens: memory },
|
|
129
|
+
{ label: "Skills", tokens: skills },
|
|
130
|
+
{ label: "Tool definitions", tokens: tools },
|
|
131
|
+
{ label: "Tool results", tokens: toolResults },
|
|
132
|
+
{ label: "Conversation", tokens: conversation },
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
parts,
|
|
137
|
+
attributed: parts.reduce((sum, part) => sum + part.tokens, 0),
|
|
138
|
+
contextWindow: Math.max(0, input.contextWindow),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const BAR_WIDTH = 28;
|
|
143
|
+
|
|
144
|
+
function bar(fraction: number): string {
|
|
145
|
+
const filled = Math.max(0, Math.min(BAR_WIDTH, Math.round(fraction * BAR_WIDTH)));
|
|
146
|
+
return `${"█".repeat(filled)}${"·".repeat(BAR_WIDTH - filled)}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function pct(tokens: number, total: number): string {
|
|
150
|
+
if (total <= 0) return " — ";
|
|
151
|
+
const value = (tokens / total) * 100;
|
|
152
|
+
if (value > 0 && value < 0.5) return " <1%";
|
|
153
|
+
return `${Math.round(value).toString().padStart(3)}%`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function formatTokens(n: number): string {
|
|
157
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
158
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
159
|
+
return String(Math.round(n));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Render the breakdown. `reportedUsed` is the provider's own number for the
|
|
164
|
+
* last request when pi has one: it is authoritative, so anything it counts
|
|
165
|
+
* that the parts do not is shown as "Other" rather than silently dropped.
|
|
166
|
+
*/
|
|
167
|
+
export function formatBreakdown(breakdown: ContextBreakdown, reportedUsed: number | null): string {
|
|
168
|
+
const window = breakdown.contextWindow;
|
|
169
|
+
const used = Math.max(reportedUsed ?? 0, breakdown.attributed);
|
|
170
|
+
const other = Math.max(0, used - breakdown.attributed);
|
|
171
|
+
const free = window > 0 ? Math.max(0, window - used) : 0;
|
|
172
|
+
|
|
173
|
+
const rows = [...breakdown.parts];
|
|
174
|
+
if (other > 0) rows.push({ label: "Other", tokens: other });
|
|
175
|
+
if (window > 0) rows.push({ label: "Free space", tokens: free });
|
|
176
|
+
|
|
177
|
+
const width = rows.reduce((m, row) => Math.max(m, row.label.length), 0);
|
|
178
|
+
const denominator = window > 0 ? window : used;
|
|
179
|
+
|
|
180
|
+
const lines = [
|
|
181
|
+
window > 0
|
|
182
|
+
? `Context window: ${formatTokens(used)} of ${formatTokens(window)} used (${pct(used, window).trim()})`
|
|
183
|
+
: `Context: ${formatTokens(used)} used (no window reported)`,
|
|
184
|
+
];
|
|
185
|
+
for (const row of rows) {
|
|
186
|
+
if (row.tokens === 0 && row.label !== "Free space") continue;
|
|
187
|
+
lines.push(
|
|
188
|
+
` ${row.label.padEnd(width)} ${bar(denominator > 0 ? row.tokens / denominator : 0)} ${pct(row.tokens, denominator)} ${formatTokens(row.tokens)}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
lines.push("Estimated locally (≈4 chars/token); provider billing may differ.");
|
|
192
|
+
return lines.join("\n");
|
|
193
|
+
}
|
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
|
+
}
|