@wayner6/pi-usage 0.1.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/LICENSE +21 -0
- package/README.md +89 -0
- package/SECURITY.md +15 -0
- package/index.ts +1 -0
- package/package.json +31 -0
- package/src/core/cache.ts +44 -0
- package/src/core/config.ts +77 -0
- package/src/core/security.ts +49 -0
- package/src/core/types.ts +67 -0
- package/src/index.ts +142 -0
- package/src/modules/provider/adapters/anthropic.ts +209 -0
- package/src/modules/provider/adapters/cliproxy-pi-bridge.ts +76 -0
- package/src/modules/provider/adapters/deepseek.ts +66 -0
- package/src/modules/provider/adapters/glm.ts +291 -0
- package/src/modules/provider/adapters/openai-codex.ts +196 -0
- package/src/modules/provider/adapters/xai.ts +178 -0
- package/src/modules/provider/controller.ts +147 -0
- package/src/modules/provider/matching.ts +228 -0
- package/src/settings.ts +23 -0
- package/src/ui/details.ts +26 -0
- package/src/ui/format.ts +62 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { Metric, ProviderTarget, UsageAdapter } from "../../core/types.ts";
|
|
2
|
+
import type { UsageConfig } from "../../core/config.ts";
|
|
3
|
+
|
|
4
|
+
export function chooseAdapter(target: ProviderTarget, adapters: UsageAdapter[], config: UsageConfig): UsageAdapter | undefined {
|
|
5
|
+
const override = config.providerOverrides[target.providerId];
|
|
6
|
+
if (override === "disabled") return undefined;
|
|
7
|
+
if (override) return adapters.find((adapter) => adapter.id === override);
|
|
8
|
+
return adapters.find((adapter) => adapter.canHandle(target));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function wildcardMatch(value: string, pattern: string): boolean {
|
|
12
|
+
const escaped = pattern.replace(/[.+?^${}()|[\\]\\\\]/g, "\\$&").replace(/\\*/g, ".*");
|
|
13
|
+
return new RegExp(`^${escaped}$`, "i").test(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface MatchedQuotaItem {
|
|
17
|
+
label: string;
|
|
18
|
+
remainingFraction: number;
|
|
19
|
+
resetAt?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface MatchedGroupQuota {
|
|
23
|
+
label: string;
|
|
24
|
+
remainingFraction: number;
|
|
25
|
+
resetAt?: string;
|
|
26
|
+
matchedModelId?: string;
|
|
27
|
+
accountProvider?: string;
|
|
28
|
+
/**
|
|
29
|
+
* If the model specifically matches multiple distinct time windows (e.g. 5h and 7d windows for Codex),
|
|
30
|
+
* they are gathered here.
|
|
31
|
+
*/
|
|
32
|
+
multiWindows?: MatchedQuotaItem[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function tokenizeModelId(id: string): string[] {
|
|
36
|
+
return id
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.split(/[^a-z0-9]+/)
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function friendlyGroupName(
|
|
43
|
+
group: { id?: string; label?: string },
|
|
44
|
+
modelId?: string,
|
|
45
|
+
accountProvider?: string,
|
|
46
|
+
): string {
|
|
47
|
+
const mTokens = tokenizeModelId(modelId ?? "");
|
|
48
|
+
const gTokens = tokenizeModelId(`${group.id ?? ""} ${group.label ?? ""}`);
|
|
49
|
+
const prov = (accountProvider ?? "").toLowerCase();
|
|
50
|
+
|
|
51
|
+
const isGemini = mTokens.includes("gemini") || gTokens.includes("gemini") || prov.includes("google") || prov.includes("gemini");
|
|
52
|
+
const isClaude = mTokens.includes("claude") || gTokens.includes("claude") || prov.includes("anthropic");
|
|
53
|
+
const isCodex = mTokens.includes("codex") || gTokens.includes("codex") || prov.includes("codex") || gTokens.includes("5h") || gTokens.includes("7d");
|
|
54
|
+
const isGpt = mTokens.includes("gpt") || mTokens.includes("openai") || prov.includes("openai");
|
|
55
|
+
|
|
56
|
+
if (isGemini) {
|
|
57
|
+
if (gTokens.includes("flash") || mTokens.includes("flash")) return "Gemini Flash";
|
|
58
|
+
if (gTokens.includes("pro") || mTokens.includes("pro")) return "Gemini Pro";
|
|
59
|
+
return "Gemini";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (isClaude) {
|
|
63
|
+
if (gTokens.includes("opus") || (gTokens.includes("thinking") && !gTokens.includes("flash"))) return "Claude Opus";
|
|
64
|
+
if (gTokens.includes("sonnet") || gTokens.includes("other")) return "Claude Sonnet";
|
|
65
|
+
if (gTokens.includes("haiku")) return "Claude Haiku";
|
|
66
|
+
return "Claude";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (isCodex || (isGpt && (gTokens.includes("5h") || gTokens.includes("primary") || gTokens.includes("7d") || gTokens.includes("secondary")))) {
|
|
70
|
+
if (gTokens.includes("5h") || gTokens.includes("primary")) return "Codex 5h";
|
|
71
|
+
if (gTokens.includes("7d") || gTokens.includes("secondary") || gTokens.includes("1w")) return "Codex 7d";
|
|
72
|
+
return "Codex";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (mTokens.includes("deepseek") || gTokens.includes("deepseek")) return "DeepSeek";
|
|
76
|
+
if (mTokens.includes("kimi") || mTokens.includes("moonshot") || gTokens.includes("kimi")) return "Kimi";
|
|
77
|
+
if (mTokens.includes("grok") || mTokens.includes("xai") || gTokens.includes("grok")) return "Grok";
|
|
78
|
+
|
|
79
|
+
return group.label || group.id || "Quota";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type RawBridgeGroup = {
|
|
83
|
+
id?: string;
|
|
84
|
+
label?: string;
|
|
85
|
+
remainingFraction?: number;
|
|
86
|
+
resetTime?: string;
|
|
87
|
+
models?: Array<{ id?: string; displayName?: string; remainingFraction?: number; resetTime?: string }>;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export type RawBridgeAccount = {
|
|
91
|
+
provider?: string;
|
|
92
|
+
label?: string;
|
|
93
|
+
disabled?: boolean;
|
|
94
|
+
unavailable?: boolean;
|
|
95
|
+
rawGroups?: unknown;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export function matchModelAcrossAccounts(
|
|
99
|
+
accounts: RawBridgeAccount[],
|
|
100
|
+
activeModelId?: string,
|
|
101
|
+
): { account: RawBridgeAccount; quota: MatchedGroupQuota } | undefined {
|
|
102
|
+
if (!accounts.length) return undefined;
|
|
103
|
+
const targetId = (activeModelId ?? "").trim().toLowerCase();
|
|
104
|
+
const targetTokens = tokenizeModelId(targetId);
|
|
105
|
+
|
|
106
|
+
interface Candidate {
|
|
107
|
+
account: RawBridgeAccount;
|
|
108
|
+
group: RawBridgeGroup;
|
|
109
|
+
score: number;
|
|
110
|
+
matchedModelId?: string | undefined;
|
|
111
|
+
isTimeWindow?: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const candidates: Candidate[] = [];
|
|
115
|
+
|
|
116
|
+
for (const account of accounts) {
|
|
117
|
+
if (account.disabled || account.unavailable) continue;
|
|
118
|
+
const groups = Array.isArray(account.rawGroups) ? (account.rawGroups as RawBridgeGroup[]) : [];
|
|
119
|
+
for (const group of groups) {
|
|
120
|
+
if (typeof group.remainingFraction !== "number") continue;
|
|
121
|
+
|
|
122
|
+
const groupModels = group.models ?? [];
|
|
123
|
+
let bestScore = 0;
|
|
124
|
+
let matchedModelId: string | undefined;
|
|
125
|
+
|
|
126
|
+
for (const m of groupModels) {
|
|
127
|
+
const mid = (m.id ?? "").trim().toLowerCase();
|
|
128
|
+
if (!mid) continue;
|
|
129
|
+
|
|
130
|
+
if (mid === targetId) {
|
|
131
|
+
bestScore = Math.max(bestScore, 100);
|
|
132
|
+
matchedModelId = mid;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (targetId.includes(mid) || mid.includes(targetId)) {
|
|
137
|
+
const score = 80 + Math.min(10, Math.floor((Math.min(mid.length, targetId.length) / Math.max(mid.length, targetId.length)) * 10));
|
|
138
|
+
if (score > bestScore) {
|
|
139
|
+
bestScore = score;
|
|
140
|
+
matchedModelId = mid;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const mTokens = tokenizeModelId(mid);
|
|
145
|
+
const overlap = mTokens.filter((t) => targetTokens.includes(t));
|
|
146
|
+
if (overlap.length >= 2) {
|
|
147
|
+
const score = 50 + overlap.length * 10;
|
|
148
|
+
if (score > bestScore) {
|
|
149
|
+
bestScore = score;
|
|
150
|
+
matchedModelId = mid;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const gTokens = tokenizeModelId(`${group.id ?? ""} ${group.label ?? ""}`);
|
|
156
|
+
const isTimeWindow = gTokens.includes("5h") || gTokens.includes("primary") || gTokens.includes("7d") || gTokens.includes("secondary");
|
|
157
|
+
|
|
158
|
+
if (bestScore < 60) {
|
|
159
|
+
const groupOverlap = gTokens.filter((t) => targetTokens.includes(t));
|
|
160
|
+
const provTokens = tokenizeModelId(account.provider ?? "");
|
|
161
|
+
const provOverlap = provTokens.filter((t) => targetTokens.includes(t));
|
|
162
|
+
|
|
163
|
+
if (groupOverlap.length > 0 || provOverlap.length > 0) {
|
|
164
|
+
const score = 25 + groupOverlap.length * 15 + provOverlap.length * 15;
|
|
165
|
+
if (score > bestScore) {
|
|
166
|
+
bestScore = score;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (bestScore > 0) {
|
|
172
|
+
candidates.push({
|
|
173
|
+
account,
|
|
174
|
+
group,
|
|
175
|
+
score: bestScore,
|
|
176
|
+
isTimeWindow,
|
|
177
|
+
...(matchedModelId ? { matchedModelId } : {}),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
184
|
+
|
|
185
|
+
const winner = candidates[0];
|
|
186
|
+
if (winner && winner.score >= 25) {
|
|
187
|
+
const matchedAccount = winner.account;
|
|
188
|
+
|
|
189
|
+
// Check if this matched account specifically has multiple time-window quotas (like 5h and 7d for Codex)
|
|
190
|
+
const groups = Array.isArray(matchedAccount.rawGroups) ? (matchedAccount.rawGroups as RawBridgeGroup[]) : [];
|
|
191
|
+
const timeWindowGroups = groups.filter((g) => {
|
|
192
|
+
if (typeof g.remainingFraction !== "number") return false;
|
|
193
|
+
const gTokens = tokenizeModelId(`${g.id ?? ""} ${g.label ?? ""}`);
|
|
194
|
+
return gTokens.includes("5h") || gTokens.includes("primary") || gTokens.includes("7d") || gTokens.includes("secondary");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
let multiWindows: MatchedQuotaItem[] | undefined;
|
|
198
|
+
if (winner.isTimeWindow && timeWindowGroups.length > 1) {
|
|
199
|
+
multiWindows = timeWindowGroups.map((g) => ({
|
|
200
|
+
label: friendlyGroupName(g, activeModelId, matchedAccount.provider),
|
|
201
|
+
remainingFraction: g.remainingFraction!,
|
|
202
|
+
...(g.resetTime ? { resetAt: g.resetTime } : {}),
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
account: matchedAccount,
|
|
208
|
+
quota: {
|
|
209
|
+
label: friendlyGroupName(winner.group, activeModelId, winner.account.provider),
|
|
210
|
+
remainingFraction: winner.group.remainingFraction!,
|
|
211
|
+
...(winner.group.resetTime ? { resetAt: winner.group.resetTime } : {}),
|
|
212
|
+
...(winner.matchedModelId ? { matchedModelId: winner.matchedModelId } : {}),
|
|
213
|
+
...(winner.account.provider ? { accountProvider: winner.account.provider } : {}),
|
|
214
|
+
...(multiWindows ? { multiWindows } : {}),
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function matchModelGroup(
|
|
223
|
+
groups: RawBridgeGroup[],
|
|
224
|
+
modelId?: string,
|
|
225
|
+
): MatchedGroupQuota | undefined {
|
|
226
|
+
const res = matchModelAcrossAccounts([{ rawGroups: groups }], modelId);
|
|
227
|
+
return res?.quota;
|
|
228
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { UsageConfig } from "./core/config.ts";
|
|
3
|
+
import { configPath, saveConfig } from "./core/config.ts";
|
|
4
|
+
|
|
5
|
+
export async function handleSettings(args: string, ctx: ExtensionCommandContext, config: UsageConfig): Promise<UsageConfig> {
|
|
6
|
+
const [key, value] = args.trim().split(/\s+/);
|
|
7
|
+
const next = structuredClone(config);
|
|
8
|
+
if (!key) {
|
|
9
|
+
ctx.ui.notify(`Config: ${configPath()}\nstatus=${next.display.status} widget=${next.display.widget} interval=${next.refresh.intervalSeconds}s timeout=${next.refresh.timeoutSeconds}s`, "info");
|
|
10
|
+
return next;
|
|
11
|
+
}
|
|
12
|
+
if (key === "widget" && /^(on|off)$/.test(value ?? "")) next.display.widget = value === "on";
|
|
13
|
+
else if (key === "status" && /^(on|off)$/.test(value ?? "")) next.display.status = value === "on";
|
|
14
|
+
else if (key === "interval" && Number.isFinite(Number(value))) next.refresh.intervalSeconds = Math.min(3600, Math.max(30, Number(value)));
|
|
15
|
+
else if (key === "timeout" && Number.isFinite(Number(value))) next.refresh.timeoutSeconds = Math.min(60, Math.max(2, Number(value)));
|
|
16
|
+
else {
|
|
17
|
+
ctx.ui.notify("Usage: /usage settings [widget|status] [on|off], or interval/timeout <seconds>", "warning");
|
|
18
|
+
return config;
|
|
19
|
+
}
|
|
20
|
+
await saveConfig(next);
|
|
21
|
+
ctx.ui.notify(`Pi Usage settings saved to ${configPath()}`, "info");
|
|
22
|
+
return next;
|
|
23
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Container, Key, Text, matchesKey } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { UsageSnapshot } from "../core/types.ts";
|
|
5
|
+
import { snapshotLines } from "./format.ts";
|
|
6
|
+
|
|
7
|
+
export async function showDetails(ctx: ExtensionCommandContext, snapshots: UsageSnapshot[]): Promise<void> {
|
|
8
|
+
const lines = snapshots.length ? snapshots.flatMap((snapshot, index) => [...(index ? [""] : []), ...snapshotLines(snapshot)]) : ["No usage data available."];
|
|
9
|
+
if (ctx.mode !== "tui") {
|
|
10
|
+
ctx.ui.notify(lines.join("\n"), snapshots.some((item) => item.state === "ok" || item.state === "stale") ? "info" : "warning");
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
await ctx.ui.custom<void>((_tui, theme, _keybindings, done) => {
|
|
14
|
+
const container = new Container();
|
|
15
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
16
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Pi Usage · Provider Usage")), 1, 0));
|
|
17
|
+
container.addChild(new Text(lines.map((line) => line.startsWith(" ") ? theme.fg("dim", line) : line).join("\n"), 1, 1));
|
|
18
|
+
container.addChild(new Text(theme.fg("dim", "Enter/Esc close"), 1, 0));
|
|
19
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
20
|
+
return {
|
|
21
|
+
render: (width: number) => container.render(width),
|
|
22
|
+
invalidate: () => container.invalidate(),
|
|
23
|
+
handleInput: (data: string) => { if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape)) done(); },
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
}
|
package/src/ui/format.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Metric, UsageSnapshot } from "../core/types.ts";
|
|
2
|
+
|
|
3
|
+
export function percentBar(value: number, width = 10): string {
|
|
4
|
+
const count = Math.round(Math.min(1, Math.max(0, value)) * width);
|
|
5
|
+
return `${"━".repeat(count)}${"─".repeat(width - count)}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function relativeTime(value?: string): string | undefined {
|
|
9
|
+
if (!value) return undefined;
|
|
10
|
+
const delta = new Date(value).getTime() - Date.now();
|
|
11
|
+
if (!Number.isFinite(delta)) return undefined;
|
|
12
|
+
if (delta <= 0) return "reset due";
|
|
13
|
+
const minutes = Math.ceil(delta / 60000);
|
|
14
|
+
if (minutes < 60) return `resets in ${minutes}m`;
|
|
15
|
+
const hours = Math.floor(minutes / 60);
|
|
16
|
+
const mins = minutes % 60;
|
|
17
|
+
if (hours < 48) return `resets in ${hours}h${mins ? ` ${mins}m` : ""}`;
|
|
18
|
+
return `resets in ${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function metricText(metric: Metric): string {
|
|
22
|
+
switch (metric.kind) {
|
|
23
|
+
case "balance": {
|
|
24
|
+
const symbol = metric.currency === "CNY" || metric.currency === "RMB" ? "¥" : metric.currency === "USD" ? "$" : `${metric.currency} `;
|
|
25
|
+
return `${metric.label}: ${symbol}${metric.amount.toFixed(2)}${metric.detail ? ` · ${metric.detail}` : ""}`;
|
|
26
|
+
}
|
|
27
|
+
case "quota-window": return `${metric.label} ${percentBar(metric.remainingFraction)} ${Math.round(metric.remainingFraction * 100)}% left${relativeTime(metric.resetAt) ? ` · ${relativeTime(metric.resetAt)}` : ""}`;
|
|
28
|
+
case "credits": return `${metric.label}: ${metric.remaining} ${metric.unit}`;
|
|
29
|
+
case "usage-limit": return `${metric.label}: ${metric.used}/${metric.limit} ${metric.unit}`;
|
|
30
|
+
case "rate-limit": return `${metric.label}: ${metric.value} ${metric.unit}`;
|
|
31
|
+
case "status": return `${metric.label}: ${metric.value}`;
|
|
32
|
+
case "custom": return `${metric.label}: ${metric.value}`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function compactSnapshot(snapshot?: UsageSnapshot): string {
|
|
37
|
+
if (!snapshot) return "Loading...";
|
|
38
|
+
if (snapshot.state !== "ok" && snapshot.state !== "stale") {
|
|
39
|
+
switch (snapshot.state) {
|
|
40
|
+
case "unauthorized": return `${snapshot.displayName} · Unauthorized`;
|
|
41
|
+
case "not-installed": return `${snapshot.displayName} · Bridge Not Found`;
|
|
42
|
+
case "unsupported": return `${snapshot.displayName} · Unsupported`;
|
|
43
|
+
case "empty": return snapshot.summary ? `${snapshot.displayName} · ${snapshot.summary}` : `${snapshot.displayName} · No Quota`;
|
|
44
|
+
default: return `${snapshot.displayName} · ${snapshot.state}`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return `${snapshot.summary ?? snapshot.displayName}${snapshot.stale ? " · stale" : ""}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function snapshotLines(snapshot: UsageSnapshot): string[] {
|
|
51
|
+
const lines = [`${snapshot.displayName} [${snapshot.state}]${snapshot.stale ? " · stale" : ""}`];
|
|
52
|
+
if (snapshot.error) lines.push(` Error: ${snapshot.error}`);
|
|
53
|
+
if (snapshot.diagnostic) lines.push(` ${snapshot.diagnostic}`);
|
|
54
|
+
for (const account of snapshot.accounts) {
|
|
55
|
+
const flags = [account.status, account.disabled ? "disabled" : undefined, account.unavailable ? "unavailable" : undefined].filter(Boolean).join(", ");
|
|
56
|
+
lines.push(` ${account.provider} · ${account.label}${flags ? ` (${flags})` : ""}`);
|
|
57
|
+
if (account.error) lines.push(` Error: ${account.error}`);
|
|
58
|
+
if (!account.metrics.length) lines.push(" No quota reported");
|
|
59
|
+
for (const metric of account.metrics) lines.push(` ${metricText(metric)}`);
|
|
60
|
+
}
|
|
61
|
+
return lines;
|
|
62
|
+
}
|