@bacnh85/pi-sub 0.1.16 → 0.1.18

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.
Files changed (2) hide show
  1. package/extensions/index.ts +618 -538
  2. package/package.json +1 -1
@@ -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
- used_percent?: number;
23
- reset_at?: number;
22
+ used_percent?: number;
23
+ reset_at?: number;
24
24
  };
25
25
 
26
26
  type UsageApiSnapshot = {
27
- primary?: UsageApiWindow;
28
- secondary?: UsageApiWindow;
29
- plan_type?: string;
27
+ primary?: UsageApiWindow;
28
+ secondary?: UsageApiWindow;
29
+ plan_type?: string;
30
30
  };
31
31
 
32
32
  type PiAuthEntry = {
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>;
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
- percent?: number;
47
- remaining?: number;
48
- remainingLabel?: string;
49
- resetLabel?: string;
46
+ percent?: number;
47
+ remaining?: number;
48
+ remainingLabel?: string;
49
+ resetLabel?: string;
50
50
  }
51
51
 
52
52
  interface SubscriptionAccountSnapshot {
53
- id?: string;
54
- isActive?: boolean;
55
- accountLabel?: string;
56
- plan?: string;
57
- fiveHour?: UsageWindow;
58
- weekly?: UsageWindow;
59
- lastActivity?: string;
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
- providerDisplayName: string;
64
- accounts: SubscriptionAccountSnapshot[];
65
- activeAccount?: SubscriptionAccountSnapshot;
66
- fetchedAt: number;
67
- error?: string;
66
+ providerDisplayName: string;
67
+ accounts: SubscriptionAccountSnapshot[];
68
+ activeAccount?: SubscriptionAccountSnapshot;
69
+ fetchedAt: number;
70
+ error?: string;
68
71
  }
69
72
 
70
73
  type SubscriptionProviderAdapter = {
71
- id: string;
72
- displayName: string;
73
- fetchUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot>;
74
+ id: string;
75
+ displayName: string;
76
+ fetchUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot>;
74
77
  };
75
78
 
76
79
  interface State {
77
- model?: ModelLike;
78
- adapter?: SubscriptionProviderAdapter;
79
- adapterId?: string;
80
- snapshot?: SubscriptionUsageSnapshot;
81
- lastRefreshAt: number;
82
- refreshGeneration: number;
83
- inFlight?: Promise<SubscriptionUsageSnapshot>;
84
- refreshTimer?: NodeJS.Timeout;
85
- debounceTimer?: NodeJS.Timeout;
86
- responseStartTime?: number;
87
- lastTokPerSec?: number;
88
- cumulativeOutput: number;
89
- cumulativeDurationMs: number;
90
- cumulativeCost: number;
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
- const provider = model?.provider?.toLowerCase() ?? "";
95
- return provider === CODEX_PROVIDER || provider.includes(CODEX_PROVIDER);
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
- return (model?.provider?.toLowerCase() ?? "") === OPC_PROVIDER;
102
+ return (model?.provider?.toLowerCase() ?? "") === OPC_PROVIDER;
100
103
  }
101
104
 
102
105
  function isZaiModel(model: ModelLike): boolean {
103
- return (model?.provider?.toLowerCase() ?? "") === ZAI_PROVIDER;
106
+ return (model?.provider?.toLowerCase() ?? "") === ZAI_PROVIDER;
104
107
  }
105
108
 
106
109
  function isZaiCodingCnModel(model: ModelLike): boolean {
107
- return (model?.provider?.toLowerCase() ?? "") === ZAI_CODING_CN_PROVIDER;
110
+ return (model?.provider?.toLowerCase() ?? "") === ZAI_CODING_CN_PROVIDER;
108
111
  }
109
112
 
110
113
  function piAuthPath(): string {
111
- const configDir = process.env.PI_CODING_AGENT_DIR?.trim() || path.join(os.homedir(), ".pi", "agent");
112
- return path.join(configDir, "auth.json");
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
- if (!token) return undefined;
117
- const parts = token.split(".");
118
- if (parts.length < 2) return undefined;
119
- try {
120
- return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record<string, any>;
121
- } catch {
122
- return undefined;
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
- const claims = decodeJwtPayload(entry.access);
128
- const profile = claims?.["https://api.openai.com/profile"];
129
- const auth = claims?.["https://api.openai.com/auth"];
130
- const email = typeof profile?.email === "string" ? profile.email : undefined;
131
- const plan = typeof auth?.chatgpt_plan_type === "string" ? planLabel(auth.chatgpt_plan_type) : undefined;
132
- const accountId = typeof entry.accountId === "string" ? entry.accountId : typeof auth?.chatgpt_account_id === "string" ? auth.chatgpt_account_id : undefined;
133
- return {
134
- id: accountId,
135
- isActive: true,
136
- accountLabel: email ?? accountId ?? "openai-codex account",
137
- plan,
138
- lastActivity: "Now",
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
- for (const value of values) {
144
- if (typeof value !== "string") continue;
145
- const trimmed = value.trim();
146
- if (trimmed.length > 0) return trimmed;
147
- }
148
- return undefined;
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
- return firstString(entry?.email, entry?.label, entry?.name, entry?.accountId);
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
- if (!key) return undefined;
157
- return createHash("sha256").update(key).digest("hex").slice(0, 8);
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
- const label = authEntryLabel(entry);
162
- if (label) return label;
163
- const fingerprint = keyFingerprint(entry?.key);
164
- return fingerprint ? `${providerLabel} key#${fingerprint}` : `${providerLabel} account`;
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
- return {
169
- id: firstString(entry?.accountId),
170
- isActive: true,
171
- accountLabel: authAccountLabel(providerLabel, entry),
172
- lastActivity: "Now",
173
- ...defaults,
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
- const label = firstString(account?.accountLabel);
179
- return label ? `(${label})` : undefined;
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
- if (!entry) return undefined;
184
- if (typeof entry.accountId === "string" && entry.accountId.length > 0) return entry.accountId;
185
- const claims = decodeJwtPayload(entry.access);
186
- const auth = claims?.["https://api.openai.com/auth"];
187
- return typeof auth?.chatgpt_account_id === "string" ? auth.chatgpt_account_id : undefined;
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
- if (!plan) return undefined;
192
- const normalized = plan.toLowerCase().replace(/[_-]+/g, " ");
193
- const labels: Record<string, string> = {
194
- free: "Free",
195
- plus: "Plus",
196
- prolite: "Pro Lite",
197
- "pro lite": "Pro Lite",
198
- pro: "Pro",
199
- team: "Business",
200
- business: "Business",
201
- enterprise: "Enterprise",
202
- edu: "Edu",
203
- unknown: "Unknown",
204
- };
205
- return labels[normalized] ?? plan;
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
- if (!resetAtSec) return undefined;
210
- const nowSec = Date.now() / 1000;
211
- const remainingSec = resetAtSec - nowSec;
212
- if (remainingSec <= 0) return "0M";
213
- const remainingMin = Math.ceil(remainingSec / 60);
214
- if (remainingMin < 60) return `${remainingMin}M`;
215
- const remainingH = Math.ceil(remainingSec / 3600);
216
- if (remainingH < 24) return `${remainingH}H`;
217
- const remainingD = Math.ceil(remainingSec / 86400);
218
- return `${remainingD}D`;
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
- if (!timestampSeconds) return undefined;
223
- const date = new Date(timestampSeconds * 1000);
224
- const now = new Date();
225
- const time = date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false });
226
- if (date.toDateString() === now.toDateString()) return time;
227
- const day = date.toLocaleDateString(undefined, { day: "numeric" });
228
- const month = date.toLocaleDateString(undefined, { month: "short" });
229
- return `${time} on ${day} ${month}`;
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
- if (!window || typeof window.used_percent !== "number") return undefined;
234
- const percent = Math.round(window.used_percent);
235
- const remaining = Math.max(0, 100 - percent);
236
- const resetLabel = formatReset(window.reset_at);
237
- const remainingLabel = formatRemainingTime(window.reset_at);
238
- return {
239
- percent,
240
- remaining,
241
- remainingLabel,
242
- resetLabel,
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
- if (!usage) return account;
248
- return {
249
- ...account,
250
- plan: planLabel(usage.plan_type) ?? account.plan,
251
- fiveHour: usageWindowFromApi(usage.primary) ?? account.fiveHour,
252
- weekly: usageWindowFromApi(usage.secondary) ?? account.weekly,
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
- if (!body || typeof body !== "object") return undefined;
258
- const root = body as any;
259
- const rateLimit = root.rate_limit;
260
- if (!rateLimit || typeof rateLimit !== "object") return undefined;
261
- const parseWindow = (window: any): UsageApiWindow | undefined => {
262
- if (!window || typeof window !== "object" || typeof window.used_percent !== "number") return undefined;
263
- return {
264
- used_percent: window.used_percent,
265
- reset_at: typeof window.reset_at === "number" ? window.reset_at : undefined,
266
- };
267
- };
268
- return {
269
- primary: parseWindow(rateLimit.primary_window),
270
- secondary: parseWindow(rateLimit.secondary_window),
271
- plan_type: typeof root.plan_type === "string" ? root.plan_type : undefined,
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
- const entry = readStoredCredential(CODEX_PROVIDER, piAuthPath()) as PiAuthEntry | undefined;
277
- const accountId = getCodexAccountId(entry);
278
- if (!entry?.access || !accountId) throw new Error("Missing openai-codex OAuth entry in Pi auth");
279
- return { ...entry, accountId };
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
- const entry = readStoredCredential(OPC_PROVIDER, piAuthPath()) as PiAuthEntry | undefined;
284
- if (!entry?.key && !entry?.accountId) throw new Error("Missing opencode-go API key or accountId in Pi auth");
285
- return authAccountSnapshot("OpenCode Go", entry, { plan: "Go" });
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
- const entry = readStoredCredential(providerId, piAuthPath()) as PiAuthEntry | undefined;
290
- if (!entry?.key) throw new Error(`Missing ${providerId} API key in Pi auth`);
291
- return { key: entry.key, account: authAccountSnapshot(label, entry) };
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
- const accountId = getCodexAccountId(entry) ?? entry.accountId;
296
- if (!accountId) throw new Error("Missing openai-codex OAuth entry in Pi auth");
297
- const timeoutSignal = AbortSignal.timeout(7_000);
298
- const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
299
- const response = await fetch(USAGE_ENDPOINT, {
300
- headers: {
301
- Accept: "application/json",
302
- Authorization: `Bearer ${entry.access}`,
303
- "ChatGPT-Account-Id": accountId,
304
- "User-Agent": "pi-sub/0.1.0",
305
- },
306
- signal: combinedSignal,
307
- });
308
- if (!response.ok) throw new Error(`usage request failed with HTTP ${response.status}`);
309
- return parseUsageResponse(await response.json());
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
- const message = error instanceof Error ? error.message : String(error || "Unknown error");
314
- if (/ENOENT|no such file/i.test(message)) return "Pi auth not found";
315
- if (/missing openai-codex/i.test(message)) return "openai-codex auth not found";
316
- if (/missing opencode-go/i.test(message)) return "opencode-go auth not found";
317
- if (/missing zai/i.test(message)) return "zai auth not found";
318
- if (/timed out|timeout|aborted/i.test(message)) return `${provider} usage refresh timed out`;
319
- if (/401|403|auth|token|unauthorized|forbidden/i.test(message)) return `${provider} auth unavailable`;
320
- return `${provider} usage unavailable`;
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
- try {
325
- const entry = await readPiCodexAuth();
326
- let activeAccount = accountFromPiAuth(entry);
327
- const usage = await fetchUsageFromPiAuth(entry, signal);
328
- activeAccount = mergeUsageIntoAccount(activeAccount, usage);
329
- return {
330
- providerDisplayName: "Codex",
331
- accounts: [activeAccount],
332
- activeAccount,
333
- fetchedAt: Date.now(),
334
- };
335
- } catch (error) {
336
- return {
337
- providerDisplayName: "Codex",
338
- accounts: [],
339
- fetchedAt: Date.now(),
340
- error: redactedError(error),
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
- try {
347
- const account = await readOpenCodeGoAuth();
348
- return {
349
- providerDisplayName: "OpenCode Go",
350
- accounts: [account],
351
- activeAccount: account,
352
- fetchedAt: Date.now(),
353
- };
354
- } catch (error) {
355
- return {
356
- providerDisplayName: "OpenCode Go",
357
- accounts: [],
358
- fetchedAt: Date.now(),
359
- error: redactedError(error, "OpenCode Go"),
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,440 @@ async function fetchOpenCodeGoUsage(_signal?: AbortSignal): Promise<Subscription
366
369
  // ---------------------------------------------------------------------------
367
370
 
368
371
  interface ZaiLimitEntry {
369
- type: string;
370
- percentage: number;
371
- nextResetTime?: number;
372
+ type: string;
373
+ percentage: number;
374
+ nextResetTime?: number;
372
375
  }
373
376
 
374
377
  interface ZaiUsageApiResponse {
375
- data?: {
376
- limits?: ZaiLimitEntry[];
377
- planName?: string;
378
- plan?: string;
379
- plan_type?: string;
380
- packageName?: string;
381
- level?: string;
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
- code: number;
387
- msg: string;
388
- success?: boolean;
389
+ code: number;
390
+ msg: string;
391
+ success?: boolean;
389
392
  }
390
393
 
391
394
  function zaiLimitToUsageWindow(limit: ZaiLimitEntry): UsageWindow | undefined {
392
- if (typeof limit.percentage !== "number") return undefined;
393
- const percent = Math.round(limit.percentage);
394
- const remaining = Math.max(0, 100 - percent);
395
- // Z.ai returns nextResetTime in epoch milliseconds; format helpers expect seconds.
396
- const resetAtSec = limit.nextResetTime ? limit.nextResetTime / 1000 : undefined;
397
- const resetLabel = formatReset(resetAtSec);
398
- const remainingLabel = formatRemainingTime(resetAtSec);
399
- return {
400
- percent,
401
- remaining,
402
- remainingLabel,
403
- resetLabel,
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
- const data = response.data;
409
- return planLabel(firstString(data?.planName, data?.plan, data?.plan_type, data?.packageName, data?.level));
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
- async function fetchUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
416
- try {
417
- const { key: apiKey, account: authAccount } = await readZaiAuth(providerId, displayName);
418
- const timeoutSignal = AbortSignal.timeout(7_000);
419
- const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
420
-
421
- const response = await fetch(usageUrl, {
422
- headers: {
423
- Accept: "application/json",
424
- Authorization: `Bearer ${apiKey}`,
425
- "User-Agent": "pi-sub/0.1.0",
426
- },
427
- signal: combinedSignal,
428
- });
429
-
430
- const body = await response.json();
431
-
432
- // Z.ai / BigModel return HTTP 200 even on auth errors: {"code":401,"msg":"...","success":false}
433
- // Also handle missing success field, empty msg, or presence of code.
434
- const apiError = body as ZaiUsageApiError;
435
- if (apiError.code >= 400 || (typeof apiError.success === "boolean" && !apiError.success) || (apiError.msg && apiError.msg.length > 0 && apiError.success === undefined)) {
436
- const message = apiError.msg || `HTTP status ${apiError.code}`;
437
- throw new Error(`${displayName} API error: ${message}`);
438
- }
439
-
440
- const parsed = body as ZaiUsageApiResponse;
441
- const tokenLimits = (parsed.data?.limits ?? [])
442
- .filter((l) => l.type === "TOKENS_LIMIT")
443
- .sort((a, b) => (a.nextResetTime ?? 0) - (b.nextResetTime ?? 0));
444
-
445
- if (tokenLimits.length === 0) {
446
- throw new Error(`No TOKENS_LIMIT entries in ${displayName} usage response`);
447
- }
448
-
449
- // The limit with the nearest reset is the 5-hour rolling window;
450
- // the next one (if present) is the weekly window.
451
- const fiveHour = zaiLimitToUsageWindow(tokenLimits[0]);
452
- const weekly = tokenLimits.length >= 2 ? zaiLimitToUsageWindow(tokenLimits[1]) : undefined;
453
-
454
- const account: SubscriptionAccountSnapshot = {
455
- ...authAccount,
456
- plan: zaiPlanLabel(parsed) ?? authAccount.plan,
457
- fiveHour,
458
- weekly,
459
- };
460
-
461
- return {
462
- providerDisplayName: displayName,
463
- accounts: [account],
464
- activeAccount: account,
465
- fetchedAt: Date.now(),
466
- };
467
- } catch (error) {
468
- return {
469
- providerDisplayName: displayName,
470
- accounts: [],
471
- fetchedAt: Date.now(),
472
- error: redactedError(error, displayName),
473
- };
474
- }
475
- }
476
- return { fetchUsage };
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
- if (isCodexModel(model)) return { id: CODEX_PROVIDER, displayName: "Codex", fetchUsage: fetchCodexUsage };
481
- if (isOpenCodeGoModel(model)) return { id: OPC_PROVIDER, displayName: "OpenCode Go", fetchUsage: fetchOpenCodeGoUsage };
482
- if (isZaiModel(model)) return { id: ZAI_PROVIDER, displayName: "Z.ai", ...zaiUsageAdapter(ZAI_PROVIDER, ZAI_USAGE_URL, "Z.ai") };
483
- 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)") };
484
- return undefined;
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
- if (!window) return "?";
489
- if (window.remainingLabel) return `${window.remaining}%/${window.remainingLabel}`;
490
- if (window.remaining !== undefined) return `${window.remaining}%`;
491
- return "?";
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
- const values: number[] = [];
496
- if (account?.fiveHour?.remaining !== undefined) values.push(account.fiveHour.remaining);
497
- if (account?.weekly?.remaining !== undefined) values.push(account.weekly.remaining);
498
- if (values.length === 0) return 100;
499
- return Math.min(...values);
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
- if (!account) return [];
504
- const segments: string[] = [];
505
- if (account.fiveHour) segments.push(`R:${formatRemaining(account.fiveHour)}`);
506
- if (account.weekly) segments.push(`W:${formatRemaining(account.weekly)}`);
507
- return segments;
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
- if (!state.adapter) {
512
- ctx.ui.setStatus(STATUS_KEY, undefined);
513
- return;
514
- }
515
- const theme = ctx.ui.theme;
516
- const snapshot = state.snapshot;
517
- let line: string;
518
- let color: "dim" | "warning" | "error" = "dim";
519
- if (!snapshot) {
520
- line = `Sub ${state.adapter.displayName} loading`;
521
- } else if (snapshot.error) {
522
- line = `Sub ${snapshot.error}`;
523
- color = "warning";
524
- } else {
525
- const account = snapshot.activeAccount;
526
- const windowParts = windowSegments(account);
527
- const accountPart = formatFooterAccount(account);
528
- const segments = accountPart ? [accountPart, ...windowParts] : [...windowParts];
529
- const cost = state.cumulativeCost;
530
- const hasWindows = windowParts.length > 0;
531
- if (cost > 0) segments.push(`$${cost.toFixed(2)}`);
532
- if (state.lastTokPerSec !== undefined) segments.push(`${state.lastTokPerSec} tok/s`);
533
- if (segments.length === 0) {
534
- line = `Sub ${state.adapter.displayName}`;
535
- } else if (!hasWindows) {
536
- line = `${state.adapter.displayName} ${segments.join(" ")}`;
537
- } else {
538
- line = segments.join(" ");
539
- }
540
- const remaining = minRemaining(account);
541
- color = remaining <= 10 ? "error" : remaining <= 20 ? "warning" : "dim";
542
- }
543
- ctx.ui.setStatus(STATUS_KEY, theme.fg(color, line));
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
- if (state.refreshTimer || !state.adapter) return;
548
- state.refreshTimer = setInterval(() => {
549
- void refreshUsage(ctx, state, false);
550
- }, REFRESH_INTERVAL_MS);
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
- if (state.refreshTimer) clearInterval(state.refreshTimer);
555
- if (state.debounceTimer) clearTimeout(state.debounceTimer);
556
- state.refreshTimer = undefined;
557
- state.debounceTimer = undefined;
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
- const nextAdapter = supportedAdapter(model);
562
- const adapterChanged = state.adapterId !== nextAdapter?.id;
632
+ const nextAdapter = supportedAdapter(model);
633
+ const adapterChanged = state.adapterId !== nextAdapter?.id;
563
634
 
564
- state.model = model;
565
- state.adapter = nextAdapter;
566
- state.adapterId = nextAdapter?.id;
635
+ state.model = model;
636
+ state.adapter = nextAdapter;
637
+ state.adapterId = nextAdapter?.id;
567
638
 
568
- if (adapterChanged) {
569
- state.snapshot = undefined;
570
- state.lastRefreshAt = 0;
571
- state.inFlight = undefined;
572
- state.refreshGeneration++;
573
- }
639
+ if (adapterChanged) {
640
+ state.snapshot = undefined;
641
+ state.lastRefreshAt = 0;
642
+ state.inFlight = undefined;
643
+ state.refreshGeneration++;
644
+ }
574
645
 
575
- if (!state.adapter) {
576
- stopTimer(state);
577
- }
578
- renderSubscriptionLine(ctx, state);
579
- if (state.adapter) startTimer(ctx, state);
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
- const adapter = state.adapter;
584
- if (!adapter) {
585
- renderSubscriptionLine(ctx, state);
586
- return undefined;
587
- }
588
- if (!force && state.snapshot && Date.now() - state.lastRefreshAt < REFRESH_TTL_MS) return state.snapshot;
589
- if (state.inFlight) return state.inFlight;
590
- const generation = state.refreshGeneration;
591
- renderSubscriptionLine(ctx, state);
592
- state.inFlight = adapter.fetchUsage(ctx.signal).then((snapshot) => {
593
- if (state.refreshGeneration !== generation) return snapshot;
594
- state.snapshot = snapshot;
595
- state.lastRefreshAt = Date.now();
596
- renderSubscriptionLine(ctx, state);
597
- return snapshot;
598
- }).finally(() => {
599
- if (state.refreshGeneration === generation) {
600
- state.inFlight = undefined;
601
- }
602
- });
603
- return state.inFlight;
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
- if (!state.adapter) return;
608
- if (state.debounceTimer) clearTimeout(state.debounceTimer);
609
- state.debounceTimer = setTimeout(() => {
610
- state.debounceTimer = undefined;
611
- void refreshUsage(ctx, state, true);
612
- }, REFRESH_DEBOUNCE_MS);
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
- return value.length >= width ? value : value + " ".repeat(width - value.length);
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
- if (!state.adapter) return `Subscription tracking inactive for current model provider (${state.model?.provider ?? "unknown"}).`;
621
- if (!snapshot) return "Subscription usage has not been loaded yet.";
622
- if (snapshot.error) return `${snapshot.providerDisplayName}: ${snapshot.error}`;
623
- if (snapshot.accounts.length === 0) {
624
- const costLine = state.cumulativeCost > 0 ? `\nSession cost: $${state.cumulativeCost.toFixed(2)}` : "";
625
- const modelInfo = state.model?.id ? ` · Model: ${state.model.id}` : "";
626
- return `Provider: ${snapshot.providerDisplayName}${modelInfo} · Fetched: ${new Date(snapshot.fetchedAt).toLocaleTimeString()}\n${snapshot.providerDisplayName} does not expose usage windows.${costLine}`;
627
- }
628
-
629
- const columns: { key: string; label: string; get: (a: SubscriptionAccountSnapshot) => string }[] = [
630
- { key: "account", label: "ACCOUNT", get: (a) => a.accountLabel ?? "unknown" },
631
- { key: "plan", label: "PLAN", get: (a) => a.plan ?? "?" },
632
- ];
633
-
634
- const hasFiveHour = snapshot.accounts.some((a) => a.fiveHour);
635
- const hasWeekly = snapshot.accounts.some((a) => a.weekly);
636
- if (hasFiveHour) columns.push({ key: "five", label: "ROLLING", get: (a) => formatRemaining(a.fiveHour) });
637
- if (hasWeekly) columns.push({ key: "weekly", label: "WEEKLY", get: (a) => formatRemaining(a.weekly) });
638
- const rows = snapshot.accounts.map((account) => ({
639
- active: account.isActive ? "*" : " ",
640
- snapshot: account,
641
- }));
642
-
643
- const widths: Record<string, number> = {};
644
- for (const col of columns) {
645
- widths[col.key] = Math.max(col.label.length, ...snapshot.accounts.map((a) => col.get(a).length));
646
- }
647
-
648
- const headerCols = columns.map((c) => pad(c.label, widths[c.key]));
649
- const header = ` ${headerCols.join(" ")} LAST ACTIVITY`;
650
- const sep = "-".repeat(header.length);
651
- const body = rows.map((row) => {
652
- const cols = columns.map((c) => pad(c.get(row.snapshot), widths[c.key]));
653
- return `${row.active} ${cols.join(" ")} ${row.snapshot.lastActivity ?? ""}`;
654
- });
655
-
656
- const costLine = state.cumulativeCost > 0 ? `\nSession cost: $${state.cumulativeCost.toFixed(2)}` : "";
657
- const tokPerSecLine = state.lastTokPerSec !== undefined
658
- ? `\nLast response: ${state.lastTokPerSec} tok/s` +
659
- (state.cumulativeDurationMs > 0
660
- ? ` · Session avg: ${Math.round(state.cumulativeOutput / (state.cumulativeDurationMs / 1000))} tok/s`
661
- : "")
662
- : "";
663
- const lines = [`Provider: ${snapshot.providerDisplayName} · Model: ${state.model?.id ?? "unknown-model"} · Fetched: ${new Date(snapshot.fetchedAt).toLocaleTimeString()}${costLine}${tokPerSecLine}`, "", header, sep, ...body];
664
- if (!hasFiveHour && !hasWeekly) {
665
- lines.push("", `${snapshot.providerDisplayName} does not expose usage windows.`);
666
- }
667
- return lines.join("\n");
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
- const state: State = { lastRefreshAt: 0, refreshGeneration: 0, cumulativeOutput: 0, cumulativeDurationMs: 0, cumulativeCost: 0 };
672
-
673
- pi.on("session_start", async (_event, ctx) => {
674
- updateActiveAdapter(ctx, state, ctx.model);
675
- if (state.adapter) void refreshUsage(ctx, state, true);
676
- });
677
-
678
- pi.on("model_select", async (event, ctx) => {
679
- updateActiveAdapter(ctx, state, event.model);
680
- if (state.adapter) void refreshUsage(ctx, state, true);
681
- });
682
-
683
- pi.on("before_provider_request", async (_event, _ctx) => {
684
- state.responseStartTime = Date.now();
685
- });
686
-
687
- pi.on("message_end", async (event, ctx) => {
688
- if (event.message.role === "assistant") {
689
- state.cumulativeCost += (event.message.usage as any)?.cost?.total ?? 0;
690
- if (state.responseStartTime) {
691
- const output = (event.message.usage as any)?.output ?? 0;
692
- const elapsed = Date.now() - state.responseStartTime;
693
- state.responseStartTime = undefined;
694
- if (elapsed > 0 && output > 0) {
695
- state.lastTokPerSec = Math.round(output / (elapsed / 1000));
696
- state.cumulativeOutput += output;
697
- state.cumulativeDurationMs += elapsed;
698
- }
699
- }
700
- if (state.adapter) renderSubscriptionLine(ctx, state);
701
- }
702
- });
703
-
704
- pi.on("after_provider_response", async (event, ctx) => {
705
- if (event.status >= 400) {
706
- state.responseStartTime = undefined;
707
- }
708
- if (state.adapter) scheduleRefresh(ctx, state);
709
- });
710
-
711
- pi.on("session_shutdown", async (_event, ctx) => {
712
- stopTimer(state);
713
- ctx.ui.setStatus(STATUS_KEY, undefined);
714
- });
715
-
716
- pi.registerCommand("sub", {
717
- description: "Show subscription usage for the current supported model provider (use /sub refresh to force refresh).",
718
- handler: async (args, ctx) => {
719
- updateActiveAdapter(ctx, state, ctx.model);
720
- const command = args.trim().toLowerCase();
721
- const force = command === "refresh";
722
- const snapshot = state.adapter ? await refreshUsage(ctx, state, force || !state.snapshot) : undefined;
723
- const details = buildDetails(snapshot ?? state.snapshot, state);
724
- pi.sendMessage({ customType: MESSAGE_TYPE, content: details, display: true });
725
- if (force) ctx.ui.notify("Subscription usage refreshed", "info");
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
+ // ponytail: session is being torn down (new/fork/switch/reload). Pi invalidates
789
+ // this ctx next; no-op any in-flight fetch .then that captured it, and drop the
790
+ // stale promise so the next session fetches fresh instead of returning it.
791
+ state.inFlight = undefined;
792
+ state.refreshGeneration++;
793
+ ctx.ui.setStatus(STATUS_KEY, undefined);
794
+ });
795
+
796
+ pi.registerCommand("sub", {
797
+ description: "Show subscription usage for the current supported model provider (use /sub refresh to force refresh).",
798
+ handler: async (args, ctx) => {
799
+ updateActiveAdapter(ctx, state, ctx.model);
800
+ const command = args.trim().toLowerCase();
801
+ const force = command === "refresh";
802
+ const snapshot = state.adapter ? await refreshUsage(ctx, state, force || !state.snapshot) : undefined;
803
+ const details = buildDetails(snapshot ?? state.snapshot, state);
804
+ pi.sendMessage({ customType: MESSAGE_TYPE, content: details, display: true });
805
+ if (force) ctx.ui.notify("Subscription usage refreshed", "info");
806
+ },
807
+ });
728
808
  }