@bacnh85/pi-sub 0.1.16 → 0.1.17
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/extensions/index.ts +613 -538
- package/package.json +1 -1
package/extensions/index.ts
CHANGED
|
@@ -19,346 +19,349 @@ const ZAI_CODING_CN_USAGE_URL = "https://open.bigmodel.cn/api/monitor/usage/quot
|
|
|
19
19
|
type ModelLike = { provider?: string; id?: string } | undefined;
|
|
20
20
|
|
|
21
21
|
type UsageApiWindow = {
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
used_percent?: number;
|
|
23
|
+
reset_at?: number;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
type UsageApiSnapshot = {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
primary?: UsageApiWindow;
|
|
28
|
+
secondary?: UsageApiWindow;
|
|
29
|
+
plan_type?: string;
|
|
30
30
|
};
|
|
31
31
|
|
|
32
32
|
type PiAuthEntry = {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
33
|
+
type?: string;
|
|
34
|
+
access?: string;
|
|
35
|
+
refresh?: string;
|
|
36
|
+
expires?: number;
|
|
37
|
+
accountId?: string;
|
|
38
|
+
key?: string;
|
|
39
|
+
email?: string;
|
|
40
|
+
label?: string;
|
|
41
|
+
name?: string;
|
|
42
|
+
env?: Record<string, string>;
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
interface UsageWindow {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
percent?: number;
|
|
47
|
+
remaining?: number;
|
|
48
|
+
remainingLabel?: string;
|
|
49
|
+
resetLabel?: string;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
interface SubscriptionAccountSnapshot {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
53
|
+
id?: string;
|
|
54
|
+
isActive?: boolean;
|
|
55
|
+
accountLabel?: string;
|
|
56
|
+
plan?: string;
|
|
57
|
+
fiveHour?: UsageWindow;
|
|
58
|
+
weekly?: UsageWindow;
|
|
59
|
+
// Z.ai-only extras surfaced in the /sub detail view.
|
|
60
|
+
mcpMonthly?: UsageWindow; // from TIME_LIMIT already present in the quota response
|
|
61
|
+
usageBreakdown?: string; // per-model / per-tool summary line(s)
|
|
62
|
+
lastActivity?: string;
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
interface SubscriptionUsageSnapshot {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
providerDisplayName: string;
|
|
67
|
+
accounts: SubscriptionAccountSnapshot[];
|
|
68
|
+
activeAccount?: SubscriptionAccountSnapshot;
|
|
69
|
+
fetchedAt: number;
|
|
70
|
+
error?: string;
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
type SubscriptionProviderAdapter = {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
+
id: string;
|
|
75
|
+
displayName: string;
|
|
76
|
+
fetchUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot>;
|
|
74
77
|
};
|
|
75
78
|
|
|
76
79
|
interface State {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
80
|
+
model?: ModelLike;
|
|
81
|
+
adapter?: SubscriptionProviderAdapter;
|
|
82
|
+
adapterId?: string;
|
|
83
|
+
snapshot?: SubscriptionUsageSnapshot;
|
|
84
|
+
lastRefreshAt: number;
|
|
85
|
+
refreshGeneration: number;
|
|
86
|
+
inFlight?: Promise<SubscriptionUsageSnapshot>;
|
|
87
|
+
refreshTimer?: NodeJS.Timeout;
|
|
88
|
+
debounceTimer?: NodeJS.Timeout;
|
|
89
|
+
responseStartTime?: number;
|
|
90
|
+
lastTokPerSec?: number;
|
|
91
|
+
cumulativeOutput: number;
|
|
92
|
+
cumulativeDurationMs: number;
|
|
93
|
+
cumulativeCost: number;
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
function isCodexModel(model: ModelLike): boolean {
|
|
94
|
-
|
|
95
|
-
|
|
97
|
+
const provider = model?.provider?.toLowerCase() ?? "";
|
|
98
|
+
return provider === CODEX_PROVIDER || provider.includes(CODEX_PROVIDER);
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
function isOpenCodeGoModel(model: ModelLike): boolean {
|
|
99
|
-
|
|
102
|
+
return (model?.provider?.toLowerCase() ?? "") === OPC_PROVIDER;
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
function isZaiModel(model: ModelLike): boolean {
|
|
103
|
-
|
|
106
|
+
return (model?.provider?.toLowerCase() ?? "") === ZAI_PROVIDER;
|
|
104
107
|
}
|
|
105
108
|
|
|
106
109
|
function isZaiCodingCnModel(model: ModelLike): boolean {
|
|
107
|
-
|
|
110
|
+
return (model?.provider?.toLowerCase() ?? "") === ZAI_CODING_CN_PROVIDER;
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
function piAuthPath(): string {
|
|
111
|
-
|
|
112
|
-
|
|
114
|
+
const configDir = process.env.PI_CODING_AGENT_DIR?.trim() || path.join(os.homedir(), ".pi", "agent");
|
|
115
|
+
return path.join(configDir, "auth.json");
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
function decodeJwtPayload(token: string | undefined): Record<string, any> | undefined {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
119
|
+
if (!token) return undefined;
|
|
120
|
+
const parts = token.split(".");
|
|
121
|
+
if (parts.length < 2) return undefined;
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record<string, any>;
|
|
124
|
+
} catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
124
127
|
}
|
|
125
128
|
|
|
126
129
|
function accountFromPiAuth(entry: PiAuthEntry): SubscriptionAccountSnapshot {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
130
|
+
const claims = decodeJwtPayload(entry.access);
|
|
131
|
+
const profile = claims?.["https://api.openai.com/profile"];
|
|
132
|
+
const auth = claims?.["https://api.openai.com/auth"];
|
|
133
|
+
const email = typeof profile?.email === "string" ? profile.email : undefined;
|
|
134
|
+
const plan = typeof auth?.chatgpt_plan_type === "string" ? planLabel(auth.chatgpt_plan_type) : undefined;
|
|
135
|
+
const accountId = typeof entry.accountId === "string" ? entry.accountId : typeof auth?.chatgpt_account_id === "string" ? auth.chatgpt_account_id : undefined;
|
|
136
|
+
return {
|
|
137
|
+
id: accountId,
|
|
138
|
+
isActive: true,
|
|
139
|
+
accountLabel: email ?? accountId ?? "openai-codex account",
|
|
140
|
+
plan,
|
|
141
|
+
lastActivity: "Now",
|
|
142
|
+
};
|
|
140
143
|
}
|
|
141
144
|
|
|
142
145
|
function firstString(...values: unknown[]): string | undefined {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
146
|
+
for (const value of values) {
|
|
147
|
+
if (typeof value !== "string") continue;
|
|
148
|
+
const trimmed = value.trim();
|
|
149
|
+
if (trimmed.length > 0) return trimmed;
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
149
152
|
}
|
|
150
153
|
|
|
151
154
|
function authEntryLabel(entry: PiAuthEntry | undefined): string | undefined {
|
|
152
|
-
|
|
155
|
+
return firstString(entry?.email, entry?.label, entry?.name, entry?.accountId);
|
|
153
156
|
}
|
|
154
157
|
|
|
155
158
|
function keyFingerprint(key: string | undefined): string | undefined {
|
|
156
|
-
|
|
157
|
-
|
|
159
|
+
if (!key) return undefined;
|
|
160
|
+
return createHash("sha256").update(key).digest("hex").slice(0, 8);
|
|
158
161
|
}
|
|
159
162
|
|
|
160
163
|
function authAccountLabel(providerLabel: string, entry: PiAuthEntry | undefined): string {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
164
|
+
const label = authEntryLabel(entry);
|
|
165
|
+
if (label) return label;
|
|
166
|
+
const fingerprint = keyFingerprint(entry?.key);
|
|
167
|
+
return fingerprint ? `${providerLabel} key#${fingerprint}` : `${providerLabel} account`;
|
|
165
168
|
}
|
|
166
169
|
|
|
167
170
|
function authAccountSnapshot(providerLabel: string, entry: PiAuthEntry | undefined, defaults: Partial<SubscriptionAccountSnapshot> = {}): SubscriptionAccountSnapshot {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
171
|
+
return {
|
|
172
|
+
id: firstString(entry?.accountId),
|
|
173
|
+
isActive: true,
|
|
174
|
+
accountLabel: authAccountLabel(providerLabel, entry),
|
|
175
|
+
lastActivity: "Now",
|
|
176
|
+
...defaults,
|
|
177
|
+
};
|
|
175
178
|
}
|
|
176
179
|
|
|
177
180
|
function formatFooterAccount(account: SubscriptionAccountSnapshot | undefined): string | undefined {
|
|
178
|
-
|
|
179
|
-
|
|
181
|
+
const label = firstString(account?.accountLabel);
|
|
182
|
+
return label ? `(${label})` : undefined;
|
|
180
183
|
}
|
|
181
184
|
|
|
182
185
|
function getCodexAccountId(entry: PiAuthEntry | undefined): string | undefined {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
186
|
+
if (!entry) return undefined;
|
|
187
|
+
if (typeof entry.accountId === "string" && entry.accountId.length > 0) return entry.accountId;
|
|
188
|
+
const claims = decodeJwtPayload(entry.access);
|
|
189
|
+
const auth = claims?.["https://api.openai.com/auth"];
|
|
190
|
+
return typeof auth?.chatgpt_account_id === "string" ? auth.chatgpt_account_id : undefined;
|
|
188
191
|
}
|
|
189
192
|
|
|
190
193
|
function planLabel(plan: string | undefined): string | undefined {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
194
|
+
if (!plan) return undefined;
|
|
195
|
+
const normalized = plan.toLowerCase().replace(/[_-]+/g, " ");
|
|
196
|
+
const labels: Record<string, string> = {
|
|
197
|
+
free: "Free",
|
|
198
|
+
plus: "Plus",
|
|
199
|
+
prolite: "Pro Lite",
|
|
200
|
+
"pro lite": "Pro Lite",
|
|
201
|
+
pro: "Pro",
|
|
202
|
+
team: "Business",
|
|
203
|
+
business: "Business",
|
|
204
|
+
enterprise: "Enterprise",
|
|
205
|
+
edu: "Edu",
|
|
206
|
+
unknown: "Unknown",
|
|
207
|
+
};
|
|
208
|
+
return labels[normalized] ?? plan;
|
|
206
209
|
}
|
|
207
210
|
|
|
208
211
|
function formatRemainingTime(resetAtSec: number | undefined): string | undefined {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
212
|
+
if (!resetAtSec) return undefined;
|
|
213
|
+
const nowSec = Date.now() / 1000;
|
|
214
|
+
const remainingSec = resetAtSec - nowSec;
|
|
215
|
+
if (remainingSec <= 0) return "0M";
|
|
216
|
+
const remainingMin = Math.ceil(remainingSec / 60);
|
|
217
|
+
if (remainingMin < 60) return `${remainingMin}M`;
|
|
218
|
+
const remainingH = Math.ceil(remainingSec / 3600);
|
|
219
|
+
if (remainingH < 24) return `${remainingH}H`;
|
|
220
|
+
const remainingD = Math.ceil(remainingSec / 86400);
|
|
221
|
+
return `${remainingD}D`;
|
|
219
222
|
}
|
|
220
223
|
|
|
221
224
|
function formatReset(timestampSeconds: number | undefined): string | undefined {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
225
|
+
if (!timestampSeconds) return undefined;
|
|
226
|
+
const date = new Date(timestampSeconds * 1000);
|
|
227
|
+
const now = new Date();
|
|
228
|
+
const time = date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false });
|
|
229
|
+
if (date.toDateString() === now.toDateString()) return time;
|
|
230
|
+
const day = date.toLocaleDateString(undefined, { day: "numeric" });
|
|
231
|
+
const month = date.toLocaleDateString(undefined, { month: "short" });
|
|
232
|
+
return `${time} on ${day} ${month}`;
|
|
230
233
|
}
|
|
231
234
|
|
|
232
235
|
function usageWindowFromApi(window: UsageApiWindow | undefined): UsageWindow | undefined {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
236
|
+
if (!window || typeof window.used_percent !== "number") return undefined;
|
|
237
|
+
const percent = Math.round(window.used_percent);
|
|
238
|
+
const remaining = Math.max(0, 100 - percent);
|
|
239
|
+
const resetLabel = formatReset(window.reset_at);
|
|
240
|
+
const remainingLabel = formatRemainingTime(window.reset_at);
|
|
241
|
+
return {
|
|
242
|
+
percent,
|
|
243
|
+
remaining,
|
|
244
|
+
remainingLabel,
|
|
245
|
+
resetLabel,
|
|
246
|
+
};
|
|
244
247
|
}
|
|
245
248
|
|
|
246
249
|
function mergeUsageIntoAccount(account: SubscriptionAccountSnapshot, usage: UsageApiSnapshot | undefined): SubscriptionAccountSnapshot {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
250
|
+
if (!usage) return account;
|
|
251
|
+
return {
|
|
252
|
+
...account,
|
|
253
|
+
plan: planLabel(usage.plan_type) ?? account.plan,
|
|
254
|
+
fiveHour: usageWindowFromApi(usage.primary) ?? account.fiveHour,
|
|
255
|
+
weekly: usageWindowFromApi(usage.secondary) ?? account.weekly,
|
|
256
|
+
};
|
|
254
257
|
}
|
|
255
258
|
|
|
256
259
|
function parseUsageResponse(body: unknown): UsageApiSnapshot | undefined {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
260
|
+
if (!body || typeof body !== "object") return undefined;
|
|
261
|
+
const root = body as any;
|
|
262
|
+
const rateLimit = root.rate_limit;
|
|
263
|
+
if (!rateLimit || typeof rateLimit !== "object") return undefined;
|
|
264
|
+
const parseWindow = (window: any): UsageApiWindow | undefined => {
|
|
265
|
+
if (!window || typeof window !== "object" || typeof window.used_percent !== "number") return undefined;
|
|
266
|
+
return {
|
|
267
|
+
used_percent: window.used_percent,
|
|
268
|
+
reset_at: typeof window.reset_at === "number" ? window.reset_at : undefined,
|
|
269
|
+
};
|
|
270
|
+
};
|
|
271
|
+
return {
|
|
272
|
+
primary: parseWindow(rateLimit.primary_window),
|
|
273
|
+
secondary: parseWindow(rateLimit.secondary_window),
|
|
274
|
+
plan_type: typeof root.plan_type === "string" ? root.plan_type : undefined,
|
|
275
|
+
};
|
|
273
276
|
}
|
|
274
277
|
|
|
275
278
|
async function readPiCodexAuth(): Promise<PiAuthEntry & { accountId: string }> {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
279
|
+
const entry = readStoredCredential(CODEX_PROVIDER, piAuthPath()) as PiAuthEntry | undefined;
|
|
280
|
+
const accountId = getCodexAccountId(entry);
|
|
281
|
+
if (!entry?.access || !accountId) throw new Error("Missing openai-codex OAuth entry in Pi auth");
|
|
282
|
+
return { ...entry, accountId };
|
|
280
283
|
}
|
|
281
284
|
|
|
282
285
|
async function readOpenCodeGoAuth(): Promise<SubscriptionAccountSnapshot> {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
+
const entry = readStoredCredential(OPC_PROVIDER, piAuthPath()) as PiAuthEntry | undefined;
|
|
287
|
+
if (!entry?.key && !entry?.accountId) throw new Error("Missing opencode-go API key or accountId in Pi auth");
|
|
288
|
+
return authAccountSnapshot("OpenCode Go", entry, { plan: "Go" });
|
|
286
289
|
}
|
|
287
290
|
|
|
288
291
|
async function readZaiAuth(providerId: string, label: string): Promise<{ key: string; account: SubscriptionAccountSnapshot }> {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
+
const entry = readStoredCredential(providerId, piAuthPath()) as PiAuthEntry | undefined;
|
|
293
|
+
if (!entry?.key) throw new Error(`Missing ${providerId} API key in Pi auth`);
|
|
294
|
+
return { key: entry.key, account: authAccountSnapshot(label, entry) };
|
|
292
295
|
}
|
|
293
296
|
|
|
294
297
|
async function fetchUsageFromPiAuth(entry: PiAuthEntry, signal?: AbortSignal): Promise<UsageApiSnapshot | undefined> {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
298
|
+
const accountId = getCodexAccountId(entry) ?? entry.accountId;
|
|
299
|
+
if (!accountId) throw new Error("Missing openai-codex OAuth entry in Pi auth");
|
|
300
|
+
const timeoutSignal = AbortSignal.timeout(7_000);
|
|
301
|
+
const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
302
|
+
const response = await fetch(USAGE_ENDPOINT, {
|
|
303
|
+
headers: {
|
|
304
|
+
Accept: "application/json",
|
|
305
|
+
Authorization: `Bearer ${entry.access}`,
|
|
306
|
+
"ChatGPT-Account-Id": accountId,
|
|
307
|
+
"User-Agent": "pi-sub/0.1.0",
|
|
308
|
+
},
|
|
309
|
+
signal: combinedSignal,
|
|
310
|
+
});
|
|
311
|
+
if (!response.ok) throw new Error(`usage request failed with HTTP ${response.status}`);
|
|
312
|
+
return parseUsageResponse(await response.json());
|
|
310
313
|
}
|
|
311
314
|
|
|
312
315
|
function redactedError(error: unknown, provider = "Codex"): string {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
316
|
+
const message = error instanceof Error ? error.message : String(error || "Unknown error");
|
|
317
|
+
if (/ENOENT|no such file/i.test(message)) return "Pi auth not found";
|
|
318
|
+
if (/missing openai-codex/i.test(message)) return "openai-codex auth not found";
|
|
319
|
+
if (/missing opencode-go/i.test(message)) return "opencode-go auth not found";
|
|
320
|
+
if (/missing zai/i.test(message)) return "zai auth not found";
|
|
321
|
+
if (/timed out|timeout|aborted/i.test(message)) return `${provider} usage refresh timed out`;
|
|
322
|
+
if (/401|403|auth|token|unauthorized|forbidden/i.test(message)) return `${provider} auth unavailable`;
|
|
323
|
+
return `${provider} usage unavailable`;
|
|
321
324
|
}
|
|
322
325
|
|
|
323
326
|
async function fetchCodexUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
327
|
+
try {
|
|
328
|
+
const entry = await readPiCodexAuth();
|
|
329
|
+
let activeAccount = accountFromPiAuth(entry);
|
|
330
|
+
const usage = await fetchUsageFromPiAuth(entry, signal);
|
|
331
|
+
activeAccount = mergeUsageIntoAccount(activeAccount, usage);
|
|
332
|
+
return {
|
|
333
|
+
providerDisplayName: "Codex",
|
|
334
|
+
accounts: [activeAccount],
|
|
335
|
+
activeAccount,
|
|
336
|
+
fetchedAt: Date.now(),
|
|
337
|
+
};
|
|
338
|
+
} catch (error) {
|
|
339
|
+
return {
|
|
340
|
+
providerDisplayName: "Codex",
|
|
341
|
+
accounts: [],
|
|
342
|
+
fetchedAt: Date.now(),
|
|
343
|
+
error: redactedError(error),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
343
346
|
}
|
|
344
347
|
|
|
345
348
|
async function fetchOpenCodeGoUsage(_signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
349
|
+
try {
|
|
350
|
+
const account = await readOpenCodeGoAuth();
|
|
351
|
+
return {
|
|
352
|
+
providerDisplayName: "OpenCode Go",
|
|
353
|
+
accounts: [account],
|
|
354
|
+
activeAccount: account,
|
|
355
|
+
fetchedAt: Date.now(),
|
|
356
|
+
};
|
|
357
|
+
} catch (error) {
|
|
358
|
+
return {
|
|
359
|
+
providerDisplayName: "OpenCode Go",
|
|
360
|
+
accounts: [],
|
|
361
|
+
fetchedAt: Date.now(),
|
|
362
|
+
error: redactedError(error, "OpenCode Go"),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
362
365
|
}
|
|
363
366
|
|
|
364
367
|
// ---------------------------------------------------------------------------
|
|
@@ -366,363 +369,435 @@ async function fetchOpenCodeGoUsage(_signal?: AbortSignal): Promise<Subscription
|
|
|
366
369
|
// ---------------------------------------------------------------------------
|
|
367
370
|
|
|
368
371
|
interface ZaiLimitEntry {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
+
type: string;
|
|
373
|
+
percentage: number;
|
|
374
|
+
nextResetTime?: number;
|
|
372
375
|
}
|
|
373
376
|
|
|
374
377
|
interface ZaiUsageApiResponse {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
378
|
+
data?: {
|
|
379
|
+
limits?: ZaiLimitEntry[];
|
|
380
|
+
planName?: string;
|
|
381
|
+
plan?: string;
|
|
382
|
+
plan_type?: string;
|
|
383
|
+
packageName?: string;
|
|
384
|
+
level?: string;
|
|
385
|
+
};
|
|
383
386
|
}
|
|
384
387
|
|
|
385
388
|
interface ZaiUsageApiError {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
+
code: number;
|
|
390
|
+
msg: string;
|
|
391
|
+
success?: boolean;
|
|
389
392
|
}
|
|
390
393
|
|
|
391
394
|
function zaiLimitToUsageWindow(limit: ZaiLimitEntry): UsageWindow | undefined {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
395
|
+
if (typeof limit.percentage !== "number") return undefined;
|
|
396
|
+
const percent = Math.round(limit.percentage);
|
|
397
|
+
const remaining = Math.max(0, 100 - percent);
|
|
398
|
+
// Z.ai returns nextResetTime in epoch milliseconds; format helpers expect seconds.
|
|
399
|
+
const resetAtSec = limit.nextResetTime ? limit.nextResetTime / 1000 : undefined;
|
|
400
|
+
const resetLabel = formatReset(resetAtSec);
|
|
401
|
+
const remainingLabel = formatRemainingTime(resetAtSec);
|
|
402
|
+
return {
|
|
403
|
+
percent,
|
|
404
|
+
remaining,
|
|
405
|
+
remainingLabel,
|
|
406
|
+
resetLabel,
|
|
407
|
+
};
|
|
405
408
|
}
|
|
406
409
|
|
|
407
410
|
function zaiPlanLabel(response: ZaiUsageApiResponse): string | undefined {
|
|
408
|
-
|
|
409
|
-
|
|
411
|
+
const data = response.data;
|
|
412
|
+
return planLabel(firstString(data?.planName, data?.plan, data?.plan_type, data?.packageName, data?.level));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function compactCount(n: number): string {
|
|
416
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
417
|
+
if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
|
|
418
|
+
return String(n);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ponytail: trailing-24h window matches Z.ai dashboard intent (chelper uses ~48h).
|
|
422
|
+
function zaiUsageTimeWindow(): string {
|
|
423
|
+
const fmt = (d: Date) => {
|
|
424
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
425
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
426
|
+
};
|
|
427
|
+
const now = new Date();
|
|
428
|
+
return `?startTime=${encodeURIComponent(fmt(new Date(now.getTime() - 86_400_000)))}&endTime=${encodeURIComponent(fmt(now))}`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Z.ai model-usage / tool-usage are time-series responses (verified live, CN host).
|
|
432
|
+
// Per-model totals live in data.totalUsage.modelSummaryList[]; tool totals are
|
|
433
|
+
// named scalars in data.totalUsage. Return undefined on any mismatch so the
|
|
434
|
+
// quota table is never affected.
|
|
435
|
+
function parseZaiModelUsage(body: unknown): string | undefined {
|
|
436
|
+
const tu = (body as any)?.data?.totalUsage;
|
|
437
|
+
const list = tu?.modelSummaryList;
|
|
438
|
+
if (!Array.isArray(list)) return undefined;
|
|
439
|
+
const entries = list
|
|
440
|
+
.map((m: any) => ({ name: m?.modelName, count: m?.totalTokens }))
|
|
441
|
+
.filter((e: { name: string; count: number }) => typeof e.name === "string" && e.name && typeof e.count === "number" && e.count > 0)
|
|
442
|
+
.sort((a, b) => b.count - a.count);
|
|
443
|
+
if (entries.length === 0) return undefined;
|
|
444
|
+
const calls = typeof tu.totalModelCallCount === "number" && tu.totalModelCallCount > 0 ? ` (${tu.totalModelCallCount} calls)` : "";
|
|
445
|
+
return `Models: ${entries.map((e) => `${e.name} ${compactCount(e.count)}`).join(" · ")}${calls}`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function parseZaiToolUsage(body: unknown): string | undefined {
|
|
449
|
+
const u = (body as any)?.data?.totalUsage;
|
|
450
|
+
if (!u || typeof u !== "object") return undefined;
|
|
451
|
+
// ponytail: fixed label map — Z.ai returns named scalar counts, not a list.
|
|
452
|
+
const labels: Record<string, string> = {
|
|
453
|
+
totalNetworkSearchCount: "search",
|
|
454
|
+
totalWebReadMcpCount: "web-read",
|
|
455
|
+
totalZreadMcpCount: "zread",
|
|
456
|
+
totalSearchMcpCount: "search-mcp",
|
|
457
|
+
};
|
|
458
|
+
const entries = Object.entries(labels)
|
|
459
|
+
.map(([field, label]) => ({ label, count: u[field] }))
|
|
460
|
+
.filter((e: { label: string; count: number }) => typeof e.count === "number" && e.count > 0);
|
|
461
|
+
if (entries.length === 0) return undefined;
|
|
462
|
+
return `Tools: ${entries.map((e) => `${e.label} ${e.count}`).join(" · ")}`;
|
|
410
463
|
}
|
|
411
464
|
|
|
412
465
|
// Factory: the international `zai` and China `zai-coding-cn` endpoints share an
|
|
413
466
|
// identical quota response; only the provider id, host, and label differ.
|
|
414
467
|
function zaiUsageAdapter(providerId: string, usageUrl: string, displayName: string): { fetchUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> } {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
468
|
+
async function fetchUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
|
|
469
|
+
try {
|
|
470
|
+
const { key: apiKey, account: authAccount } = await readZaiAuth(providerId, displayName);
|
|
471
|
+
const timeoutSignal = AbortSignal.timeout(7_000);
|
|
472
|
+
const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
473
|
+
|
|
474
|
+
const headers = {
|
|
475
|
+
Accept: "application/json",
|
|
476
|
+
Authorization: `Bearer ${apiKey}`,
|
|
477
|
+
"User-Agent": "pi-sub/0.1.0",
|
|
478
|
+
};
|
|
479
|
+
const response = await fetch(usageUrl, { headers, signal: combinedSignal });
|
|
480
|
+
|
|
481
|
+
const body = await response.json();
|
|
482
|
+
|
|
483
|
+
// Z.ai / BigModel return HTTP 200 even on auth errors: {"code":401,"msg":"...","success":false}
|
|
484
|
+
// Also handle missing success field, empty msg, or presence of code.
|
|
485
|
+
const apiError = body as ZaiUsageApiError;
|
|
486
|
+
if (apiError.code >= 400 || (typeof apiError.success === "boolean" && !apiError.success) || (apiError.msg && apiError.msg.length > 0 && apiError.success === undefined)) {
|
|
487
|
+
const message = apiError.msg || `HTTP status ${apiError.code}`;
|
|
488
|
+
throw new Error(`${displayName} API error: ${message}`);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const parsed = body as ZaiUsageApiResponse;
|
|
492
|
+
const limits = parsed.data?.limits ?? [];
|
|
493
|
+
const tokenLimits = limits
|
|
494
|
+
.filter((l) => l.type === "TOKENS_LIMIT")
|
|
495
|
+
.sort((a, b) => (a.nextResetTime ?? 0) - (b.nextResetTime ?? 0));
|
|
496
|
+
// TIME_LIMIT is the MCP/month allowance already present in this response.
|
|
497
|
+
const timeLimit = limits.find((l) => l.type === "TIME_LIMIT");
|
|
498
|
+
|
|
499
|
+
if (tokenLimits.length === 0) {
|
|
500
|
+
throw new Error(`No TOKENS_LIMIT entries in ${displayName} usage response`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// The limit with the nearest reset is the 5-hour rolling window;
|
|
504
|
+
// the next one (if present) is the weekly window.
|
|
505
|
+
const fiveHour = zaiLimitToUsageWindow(tokenLimits[0]);
|
|
506
|
+
const weekly = tokenLimits.length >= 2 ? zaiLimitToUsageWindow(tokenLimits[1]) : undefined;
|
|
507
|
+
const mcpMonthly = timeLimit ? zaiLimitToUsageWindow(timeLimit) : undefined;
|
|
508
|
+
|
|
509
|
+
// Best-effort: per-model tokens + per-tool calls. Any failure is silent; the
|
|
510
|
+
// quota table above is the source of truth and never depends on these.
|
|
511
|
+
const window = zaiUsageTimeWindow();
|
|
512
|
+
const modelUrl = usageUrl.replace(/\/quota\/limit$/, "/model-usage") + window;
|
|
513
|
+
const toolUrl = usageUrl.replace(/\/quota\/limit$/, "/tool-usage") + window;
|
|
514
|
+
const [modelRes, toolRes] = await Promise.allSettled([
|
|
515
|
+
fetch(modelUrl, { headers, signal: combinedSignal }).then((r) => r.json()),
|
|
516
|
+
fetch(toolUrl, { headers, signal: combinedSignal }).then((r) => r.json()),
|
|
517
|
+
]);
|
|
518
|
+
const breakdowns = [
|
|
519
|
+
modelRes.status === "fulfilled" ? parseZaiModelUsage(modelRes.value) : undefined,
|
|
520
|
+
toolRes.status === "fulfilled" ? parseZaiToolUsage(toolRes.value) : undefined,
|
|
521
|
+
].filter((s): s is string => !!s);
|
|
522
|
+
|
|
523
|
+
const account: SubscriptionAccountSnapshot = {
|
|
524
|
+
...authAccount,
|
|
525
|
+
plan: zaiPlanLabel(parsed) ?? authAccount.plan,
|
|
526
|
+
fiveHour,
|
|
527
|
+
weekly,
|
|
528
|
+
mcpMonthly,
|
|
529
|
+
usageBreakdown: breakdowns.length > 0 ? breakdowns.join("\n") : undefined,
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
return {
|
|
533
|
+
providerDisplayName: displayName,
|
|
534
|
+
accounts: [account],
|
|
535
|
+
activeAccount: account,
|
|
536
|
+
fetchedAt: Date.now(),
|
|
537
|
+
};
|
|
538
|
+
} catch (error) {
|
|
539
|
+
return {
|
|
540
|
+
providerDisplayName: displayName,
|
|
541
|
+
accounts: [],
|
|
542
|
+
fetchedAt: Date.now(),
|
|
543
|
+
error: redactedError(error, displayName),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return { fetchUsage };
|
|
477
548
|
}
|
|
478
549
|
|
|
479
550
|
function supportedAdapter(model: ModelLike): SubscriptionProviderAdapter | undefined {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
551
|
+
if (isCodexModel(model)) return { id: CODEX_PROVIDER, displayName: "Codex", fetchUsage: fetchCodexUsage };
|
|
552
|
+
if (isOpenCodeGoModel(model)) return { id: OPC_PROVIDER, displayName: "OpenCode Go", fetchUsage: fetchOpenCodeGoUsage };
|
|
553
|
+
if (isZaiModel(model)) return { id: ZAI_PROVIDER, displayName: "Z.ai", ...zaiUsageAdapter(ZAI_PROVIDER, ZAI_USAGE_URL, "Z.ai") };
|
|
554
|
+
if (isZaiCodingCnModel(model)) return { id: ZAI_CODING_CN_PROVIDER, displayName: "Z.ai (CN)", ...zaiUsageAdapter(ZAI_CODING_CN_PROVIDER, ZAI_CODING_CN_USAGE_URL, "Z.ai (CN)") };
|
|
555
|
+
return undefined;
|
|
485
556
|
}
|
|
486
557
|
|
|
487
558
|
function formatRemaining(window: UsageWindow | undefined): string {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
559
|
+
if (!window) return "?";
|
|
560
|
+
if (window.remainingLabel) return `${window.remaining}%/${window.remainingLabel}`;
|
|
561
|
+
if (window.remaining !== undefined) return `${window.remaining}%`;
|
|
562
|
+
return "?";
|
|
492
563
|
}
|
|
493
564
|
|
|
494
565
|
function minRemaining(account: SubscriptionAccountSnapshot | undefined): number {
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
566
|
+
const values: number[] = [];
|
|
567
|
+
if (account?.fiveHour?.remaining !== undefined) values.push(account.fiveHour.remaining);
|
|
568
|
+
if (account?.weekly?.remaining !== undefined) values.push(account.weekly.remaining);
|
|
569
|
+
if (values.length === 0) return 100;
|
|
570
|
+
return Math.min(...values);
|
|
500
571
|
}
|
|
501
572
|
|
|
502
573
|
function windowSegments(account: SubscriptionAccountSnapshot | undefined): string[] {
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
574
|
+
if (!account) return [];
|
|
575
|
+
const segments: string[] = [];
|
|
576
|
+
if (account.fiveHour) segments.push(`R:${formatRemaining(account.fiveHour)}`);
|
|
577
|
+
if (account.weekly) segments.push(`W:${formatRemaining(account.weekly)}`);
|
|
578
|
+
return segments;
|
|
508
579
|
}
|
|
509
580
|
|
|
510
581
|
function renderSubscriptionLine(ctx: ExtensionContext, state: State): void {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
582
|
+
if (!state.adapter) {
|
|
583
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const theme = ctx.ui.theme;
|
|
587
|
+
const snapshot = state.snapshot;
|
|
588
|
+
let line: string;
|
|
589
|
+
let color: "dim" | "warning" | "error" = "dim";
|
|
590
|
+
if (!snapshot) {
|
|
591
|
+
line = `Sub ${state.adapter.displayName} loading`;
|
|
592
|
+
} else if (snapshot.error) {
|
|
593
|
+
line = `Sub ${snapshot.error}`;
|
|
594
|
+
color = "warning";
|
|
595
|
+
} else {
|
|
596
|
+
const account = snapshot.activeAccount;
|
|
597
|
+
const windowParts = windowSegments(account);
|
|
598
|
+
const accountPart = formatFooterAccount(account);
|
|
599
|
+
const segments = accountPart ? [accountPart, ...windowParts] : [...windowParts];
|
|
600
|
+
const cost = state.cumulativeCost;
|
|
601
|
+
const hasWindows = windowParts.length > 0;
|
|
602
|
+
if (cost > 0) segments.push(`$${cost.toFixed(2)}`);
|
|
603
|
+
if (state.lastTokPerSec !== undefined) segments.push(`${state.lastTokPerSec} tok/s`);
|
|
604
|
+
if (segments.length === 0) {
|
|
605
|
+
line = `Sub ${state.adapter.displayName}`;
|
|
606
|
+
} else if (!hasWindows) {
|
|
607
|
+
line = `${state.adapter.displayName} ${segments.join(" ")}`;
|
|
608
|
+
} else {
|
|
609
|
+
line = segments.join(" ");
|
|
610
|
+
}
|
|
611
|
+
const remaining = minRemaining(account);
|
|
612
|
+
color = remaining <= 10 ? "error" : remaining <= 20 ? "warning" : "dim";
|
|
613
|
+
}
|
|
614
|
+
ctx.ui.setStatus(STATUS_KEY, theme.fg(color, line));
|
|
544
615
|
}
|
|
545
616
|
|
|
546
617
|
function startTimer(ctx: ExtensionContext, state: State): void {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
618
|
+
if (state.refreshTimer || !state.adapter) return;
|
|
619
|
+
state.refreshTimer = setInterval(() => {
|
|
620
|
+
void refreshUsage(ctx, state, false);
|
|
621
|
+
}, REFRESH_INTERVAL_MS);
|
|
551
622
|
}
|
|
552
623
|
|
|
553
624
|
function stopTimer(state: State): void {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
625
|
+
if (state.refreshTimer) clearInterval(state.refreshTimer);
|
|
626
|
+
if (state.debounceTimer) clearTimeout(state.debounceTimer);
|
|
627
|
+
state.refreshTimer = undefined;
|
|
628
|
+
state.debounceTimer = undefined;
|
|
558
629
|
}
|
|
559
630
|
|
|
560
631
|
function updateActiveAdapter(ctx: ExtensionContext, state: State, model: ModelLike): void {
|
|
561
|
-
|
|
562
|
-
|
|
632
|
+
const nextAdapter = supportedAdapter(model);
|
|
633
|
+
const adapterChanged = state.adapterId !== nextAdapter?.id;
|
|
563
634
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
635
|
+
state.model = model;
|
|
636
|
+
state.adapter = nextAdapter;
|
|
637
|
+
state.adapterId = nextAdapter?.id;
|
|
567
638
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
639
|
+
if (adapterChanged) {
|
|
640
|
+
state.snapshot = undefined;
|
|
641
|
+
state.lastRefreshAt = 0;
|
|
642
|
+
state.inFlight = undefined;
|
|
643
|
+
state.refreshGeneration++;
|
|
644
|
+
}
|
|
574
645
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
646
|
+
if (!state.adapter) {
|
|
647
|
+
stopTimer(state);
|
|
648
|
+
}
|
|
649
|
+
renderSubscriptionLine(ctx, state);
|
|
650
|
+
if (state.adapter) startTimer(ctx, state);
|
|
580
651
|
}
|
|
581
652
|
|
|
582
653
|
async function refreshUsage(ctx: ExtensionContext, state: State, force: boolean): Promise<SubscriptionUsageSnapshot | undefined> {
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
654
|
+
const adapter = state.adapter;
|
|
655
|
+
if (!adapter) {
|
|
656
|
+
renderSubscriptionLine(ctx, state);
|
|
657
|
+
return undefined;
|
|
658
|
+
}
|
|
659
|
+
if (!force && state.snapshot && Date.now() - state.lastRefreshAt < REFRESH_TTL_MS) return state.snapshot;
|
|
660
|
+
if (state.inFlight) return state.inFlight;
|
|
661
|
+
const generation = state.refreshGeneration;
|
|
662
|
+
renderSubscriptionLine(ctx, state);
|
|
663
|
+
state.inFlight = adapter.fetchUsage(ctx.signal).then((snapshot) => {
|
|
664
|
+
if (state.refreshGeneration !== generation) return snapshot;
|
|
665
|
+
state.snapshot = snapshot;
|
|
666
|
+
state.lastRefreshAt = Date.now();
|
|
667
|
+
renderSubscriptionLine(ctx, state);
|
|
668
|
+
return snapshot;
|
|
669
|
+
}).finally(() => {
|
|
670
|
+
if (state.refreshGeneration === generation) {
|
|
671
|
+
state.inFlight = undefined;
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
return state.inFlight;
|
|
604
675
|
}
|
|
605
676
|
|
|
606
677
|
function scheduleRefresh(ctx: ExtensionContext, state: State): void {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
678
|
+
if (!state.adapter) return;
|
|
679
|
+
if (state.debounceTimer) clearTimeout(state.debounceTimer);
|
|
680
|
+
state.debounceTimer = setTimeout(() => {
|
|
681
|
+
state.debounceTimer = undefined;
|
|
682
|
+
void refreshUsage(ctx, state, true);
|
|
683
|
+
}, REFRESH_DEBOUNCE_MS);
|
|
613
684
|
}
|
|
614
685
|
|
|
615
686
|
function pad(value: string, width: number): string {
|
|
616
|
-
|
|
687
|
+
return value.length >= width ? value : value + " ".repeat(width - value.length);
|
|
617
688
|
}
|
|
618
689
|
|
|
619
690
|
function buildDetails(snapshot: SubscriptionUsageSnapshot | undefined, state: State): string {
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
691
|
+
if (!state.adapter) return `Subscription tracking inactive for current model provider (${state.model?.provider ?? "unknown"}).`;
|
|
692
|
+
if (!snapshot) return "Subscription usage has not been loaded yet.";
|
|
693
|
+
if (snapshot.error) return `${snapshot.providerDisplayName}: ${snapshot.error}`;
|
|
694
|
+
if (snapshot.accounts.length === 0) {
|
|
695
|
+
const costLine = state.cumulativeCost > 0 ? `\nSession cost: $${state.cumulativeCost.toFixed(2)}` : "";
|
|
696
|
+
const modelInfo = state.model?.id ? ` · Model: ${state.model.id}` : "";
|
|
697
|
+
return `Provider: ${snapshot.providerDisplayName}${modelInfo} · Fetched: ${new Date(snapshot.fetchedAt).toLocaleTimeString()}\n${snapshot.providerDisplayName} does not expose usage windows.${costLine}`;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const columns: { key: string; label: string; get: (a: SubscriptionAccountSnapshot) => string }[] = [
|
|
701
|
+
{ key: "account", label: "ACCOUNT", get: (a) => a.accountLabel ?? "unknown" },
|
|
702
|
+
{ key: "plan", label: "PLAN", get: (a) => a.plan ?? "?" },
|
|
703
|
+
];
|
|
704
|
+
|
|
705
|
+
const hasFiveHour = snapshot.accounts.some((a) => a.fiveHour);
|
|
706
|
+
const hasWeekly = snapshot.accounts.some((a) => a.weekly);
|
|
707
|
+
if (hasFiveHour) columns.push({ key: "five", label: "ROLLING", get: (a) => formatRemaining(a.fiveHour) });
|
|
708
|
+
if (hasWeekly) columns.push({ key: "weekly", label: "WEEKLY", get: (a) => formatRemaining(a.weekly) });
|
|
709
|
+
const rows = snapshot.accounts.map((account) => ({
|
|
710
|
+
active: account.isActive ? "*" : " ",
|
|
711
|
+
snapshot: account,
|
|
712
|
+
}));
|
|
713
|
+
|
|
714
|
+
const widths: Record<string, number> = {};
|
|
715
|
+
for (const col of columns) {
|
|
716
|
+
widths[col.key] = Math.max(col.label.length, ...snapshot.accounts.map((a) => col.get(a).length));
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const headerCols = columns.map((c) => pad(c.label, widths[c.key]));
|
|
720
|
+
const header = ` ${headerCols.join(" ")} LAST ACTIVITY`;
|
|
721
|
+
const sep = "-".repeat(header.length);
|
|
722
|
+
const body = rows.map((row) => {
|
|
723
|
+
const cols = columns.map((c) => pad(c.get(row.snapshot), widths[c.key]));
|
|
724
|
+
return `${row.active} ${cols.join(" ")} ${row.snapshot.lastActivity ?? ""}`;
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
const costLine = state.cumulativeCost > 0 ? `\nSession cost: $${state.cumulativeCost.toFixed(2)}` : "";
|
|
728
|
+
const tokPerSecLine = state.lastTokPerSec !== undefined
|
|
729
|
+
? `\nLast response: ${state.lastTokPerSec} tok/s` +
|
|
730
|
+
(state.cumulativeDurationMs > 0
|
|
731
|
+
? ` · Session avg: ${Math.round(state.cumulativeOutput / (state.cumulativeDurationMs / 1000))} tok/s`
|
|
732
|
+
: "")
|
|
733
|
+
: "";
|
|
734
|
+
const lines = [`Provider: ${snapshot.providerDisplayName} · Model: ${state.model?.id ?? "unknown-model"} · Fetched: ${new Date(snapshot.fetchedAt).toLocaleTimeString()}${costLine}${tokPerSecLine}`, "", header, sep, ...body];
|
|
735
|
+
if (!hasFiveHour && !hasWeekly) {
|
|
736
|
+
lines.push("", `${snapshot.providerDisplayName} does not expose usage windows.`);
|
|
737
|
+
}
|
|
738
|
+
// Z.ai extras: MCP/month allowance (from TIME_LIMIT) + per-model/per-tool breakdown.
|
|
739
|
+
const mcpAcct = snapshot.accounts.find((a) => a.mcpMonthly);
|
|
740
|
+
if (mcpAcct && mcpAcct.mcpMonthly) lines.push("", `MCP/month: ${formatRemaining(mcpAcct.mcpMonthly)}`);
|
|
741
|
+
for (const a of snapshot.accounts) if (a.usageBreakdown) lines.push("", a.usageBreakdown);
|
|
742
|
+
return lines.join("\n");
|
|
668
743
|
}
|
|
669
744
|
|
|
670
745
|
export default function (pi: ExtensionAPI) {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
746
|
+
const state: State = { lastRefreshAt: 0, refreshGeneration: 0, cumulativeOutput: 0, cumulativeDurationMs: 0, cumulativeCost: 0 };
|
|
747
|
+
|
|
748
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
749
|
+
updateActiveAdapter(ctx, state, ctx.model);
|
|
750
|
+
if (state.adapter) void refreshUsage(ctx, state, true);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
pi.on("model_select", async (event, ctx) => {
|
|
754
|
+
updateActiveAdapter(ctx, state, event.model);
|
|
755
|
+
if (state.adapter) void refreshUsage(ctx, state, true);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
pi.on("before_provider_request", async (_event, _ctx) => {
|
|
759
|
+
state.responseStartTime = Date.now();
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
pi.on("message_end", async (event, ctx) => {
|
|
763
|
+
if (event.message.role === "assistant") {
|
|
764
|
+
state.cumulativeCost += (event.message.usage as any)?.cost?.total ?? 0;
|
|
765
|
+
if (state.responseStartTime) {
|
|
766
|
+
const output = (event.message.usage as any)?.output ?? 0;
|
|
767
|
+
const elapsed = Date.now() - state.responseStartTime;
|
|
768
|
+
state.responseStartTime = undefined;
|
|
769
|
+
if (elapsed > 0 && output > 0) {
|
|
770
|
+
state.lastTokPerSec = Math.round(output / (elapsed / 1000));
|
|
771
|
+
state.cumulativeOutput += output;
|
|
772
|
+
state.cumulativeDurationMs += elapsed;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
if (state.adapter) renderSubscriptionLine(ctx, state);
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
pi.on("after_provider_response", async (event, ctx) => {
|
|
780
|
+
if (event.status >= 400) {
|
|
781
|
+
state.responseStartTime = undefined;
|
|
782
|
+
}
|
|
783
|
+
if (state.adapter) scheduleRefresh(ctx, state);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
787
|
+
stopTimer(state);
|
|
788
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
pi.registerCommand("sub", {
|
|
792
|
+
description: "Show subscription usage for the current supported model provider (use /sub refresh to force refresh).",
|
|
793
|
+
handler: async (args, ctx) => {
|
|
794
|
+
updateActiveAdapter(ctx, state, ctx.model);
|
|
795
|
+
const command = args.trim().toLowerCase();
|
|
796
|
+
const force = command === "refresh";
|
|
797
|
+
const snapshot = state.adapter ? await refreshUsage(ctx, state, force || !state.snapshot) : undefined;
|
|
798
|
+
const details = buildDetails(snapshot ?? state.snapshot, state);
|
|
799
|
+
pi.sendMessage({ customType: MESSAGE_TYPE, content: details, display: true });
|
|
800
|
+
if (force) ctx.ui.notify("Subscription usage refreshed", "info");
|
|
801
|
+
},
|
|
802
|
+
});
|
|
728
803
|
}
|