@latentminds/pi-quotas 0.2.4 → 0.3.1

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.
@@ -28,6 +28,21 @@ import { formatWindowStatus, type WindowStatus } from "./format-status.js";
28
28
 
29
29
  const EXTENSION_ID = "pi-quotas-usage";
30
30
  const REFRESH_INTERVAL_MS = 60_000;
31
+ const STALE_CONTEXT_MESSAGE = "This extension ctx is stale";
32
+
33
+ function isStaleContextError(error: unknown): boolean {
34
+ return error instanceof Error && error.message.includes(STALE_CONTEXT_MESSAGE);
35
+ }
36
+
37
+ function getContextProvider(ctx: ExtensionContext | undefined): string | undefined {
38
+ if (!ctx) return undefined;
39
+ try {
40
+ return ctx.model?.provider;
41
+ } catch (error) {
42
+ if (isStaleContextError(error)) return undefined;
43
+ throw error;
44
+ }
45
+ }
31
46
 
32
47
  function formatFooterResetTime(resetsAt: string): string {
33
48
  const remaining = formatTimeRemaining(new Date(resetsAt));
@@ -93,30 +108,75 @@ function createStatusRefresher() {
93
108
  let inFlight = false;
94
109
  let queued = false;
95
110
 
96
- async function update(ctx: ExtensionContext): Promise<void> {
97
- if (!ctx.hasUI || !activeProvider || !isSupportedProvider(activeProvider)) return;
111
+ // Bumped whenever the active ctx/provider is replaced or the refresher stops.
112
+ // This prevents an old async fetch from writing to a replacement session.
113
+ let generation = 0;
114
+
115
+ function deactivate(): void {
116
+ if (refreshTimer) clearInterval(refreshTimer);
117
+ refreshTimer = undefined;
118
+ activeContext = undefined;
119
+ activeProvider = undefined;
120
+ lastStatus = undefined;
121
+ queued = false;
122
+ generation++;
123
+ }
124
+
125
+ function setStatusSafely(
126
+ ctx: ExtensionContext | undefined,
127
+ text: string | undefined | ((ctx: ExtensionContext) => string | undefined),
128
+ ): boolean {
129
+ if (!ctx) return false;
130
+ try {
131
+ if (!ctx.hasUI) return true;
132
+ ctx.ui.setStatus(EXTENSION_ID, typeof text === "function" ? text(ctx) : text);
133
+ return true;
134
+ } catch (error) {
135
+ if (isStaleContextError(error) && activeContext === ctx) deactivate();
136
+ return false;
137
+ }
138
+ }
139
+
140
+ async function update(ctx: ExtensionContext, requestGeneration = generation): Promise<void> {
98
141
  if (inFlight) {
99
142
  queued = true;
100
143
  return;
101
144
  }
102
145
  inFlight = true;
103
146
  try {
104
- const result = await fetchProviderQuotas(ctx.modelRegistry.authStorage, activeProvider);
147
+ if (requestGeneration !== generation || activeContext !== ctx) return;
148
+ if (!ctx.hasUI || !activeProvider || !isSupportedProvider(activeProvider)) return;
149
+
150
+ const provider = activeProvider;
151
+ const result = await fetchProviderQuotas(ctx.modelRegistry.authStorage, provider);
152
+ if (requestGeneration !== generation || activeContext !== ctx) return;
153
+
105
154
  if (!result.success) {
106
- ctx.ui.setStatus(EXTENSION_ID, ctx.ui.theme.fg("warning", "usage unavailable"));
155
+ // A "not applicable" result (e.g. a direct Anthropic API key with no
156
+ // OAuth subscription usage) is expected, not a failure — show nothing
157
+ // rather than a persistent "usage unavailable" warning.
158
+ if (result.error.kind === "not_applicable") {
159
+ setStatusSafely(ctx, undefined);
160
+ return;
161
+ }
162
+ setStatusSafely(ctx, (ctx) => ctx.ui.theme.fg("warning", "usage unavailable"));
107
163
  return;
108
164
  }
109
165
  const windows: WindowStatus[] = toStatusWindows(result.data.windows);
110
166
  const status = formatStatusForFooter(ctx, windows);
111
167
  lastStatus = status === undefined ? undefined : windows;
112
- ctx.ui.setStatus(EXTENSION_ID, status);
113
- } catch {
114
- ctx.ui.setStatus(EXTENSION_ID, ctx.ui.theme.fg("warning", "usage unavailable"));
168
+ setStatusSafely(ctx, status);
169
+ } catch (error) {
170
+ if (isStaleContextError(error)) {
171
+ if (activeContext === ctx) deactivate();
172
+ return;
173
+ }
174
+ setStatusSafely(ctx, (ctx) => ctx.ui.theme.fg("warning", "usage unavailable"));
115
175
  } finally {
116
176
  inFlight = false;
117
- if (queued) {
177
+ if (queued && activeContext) {
118
178
  queued = false;
119
- void update(ctx);
179
+ void update(activeContext, generation).catch(() => undefined);
120
180
  }
121
181
  }
122
182
  }
@@ -124,32 +184,29 @@ function createStatusRefresher() {
124
184
  return {
125
185
  async refreshFor(ctx: ExtensionContext): Promise<void> {
126
186
  activeContext = ctx;
127
- activeProvider = ctx.model?.provider;
187
+ activeProvider = getContextProvider(ctx);
188
+ generation++;
189
+ const requestGeneration = generation;
128
190
  if (!activeProvider || !isSupportedProvider(activeProvider)) {
129
- ctx.ui.setStatus(EXTENSION_ID, undefined);
191
+ setStatusSafely(ctx, undefined);
130
192
  return;
131
193
  }
132
- await update(ctx);
194
+ await update(ctx, requestGeneration);
133
195
  },
134
196
  start(): void {
135
197
  if (refreshTimer) clearInterval(refreshTimer);
136
198
  refreshTimer = setInterval(() => {
137
- if (activeContext) void update(activeContext);
199
+ if (activeContext) void update(activeContext, generation).catch(() => undefined);
138
200
  }, REFRESH_INTERVAL_MS);
139
201
  refreshTimer.unref?.();
140
202
  },
141
203
  stop(ctx?: ExtensionContext): void {
142
- if (refreshTimer) clearInterval(refreshTimer);
143
- refreshTimer = undefined;
144
- activeContext = undefined;
145
- activeProvider = undefined;
146
- lastStatus = undefined;
147
- ctx?.ui.setStatus(EXTENSION_ID, undefined);
204
+ deactivate();
205
+ setStatusSafely(ctx, undefined);
148
206
  },
149
207
  renderLast(ctx: ExtensionContext): boolean {
150
- if (!lastStatus || !ctx.hasUI) return false;
151
- ctx.ui.setStatus(EXTENSION_ID, formatStatusForFooter(ctx, lastStatus));
152
- return true;
208
+ if (!lastStatus) return false;
209
+ return setStatusSafely(ctx, (ctx) => formatStatusForFooter(ctx, lastStatus ?? []));
153
210
  },
154
211
  };
155
212
  }
@@ -157,6 +214,7 @@ function createStatusRefresher() {
157
214
  export default async function (pi: ExtensionAPI) {
158
215
  await configLoader.load();
159
216
  const refresher = createStatusRefresher();
217
+ const unsubscribeEventBusListeners: Array<() => void> = [];
160
218
  let enabled = configLoader.getConfig().usageStatus;
161
219
  let deferToSynthetic = configLoader.getConfig().deferToSynthetic;
162
220
  let currentContext: ExtensionContext | undefined;
@@ -164,19 +222,22 @@ export default async function (pi: ExtensionAPI) {
164
222
  /** Whether pi-synthetic's usage footer is active in this session. */
165
223
  let syntheticUsageActive = false;
166
224
 
167
- pi.events.on(SYNTHETIC_EXTENSIONS_REGISTER_EVENT, (data: unknown) => {
225
+ unsubscribeEventBusListeners.push(pi.events.on(SYNTHETIC_EXTENSIONS_REGISTER_EVENT, (data: unknown) => {
168
226
  const { feature } = data as SyntheticExtensionsRegisterPayload;
169
227
  if (feature === "usageStatus") {
170
228
  syntheticUsageActive = true;
171
229
  // If currently showing synthetic data, clear our footer
172
- if (currentContext && enabled && deferToSynthetic && currentContext.model?.provider === "synthetic") {
173
- currentContext.ui.setStatus(EXTENSION_ID, undefined);
174
- refresher.stop();
230
+ if (currentContext && enabled && deferToSynthetic && getContextProvider(currentContext) === "synthetic") {
231
+ refresher.stop(currentContext);
175
232
  }
176
233
  }
177
- });
234
+ }));
178
235
 
179
- pi.events.on(QUOTAS_CONFIG_UPDATED_EVENT, (data: unknown) => {
236
+ function scheduleRefresh(ctx: ExtensionContext): void {
237
+ void refresher.refreshFor(ctx).catch(() => undefined);
238
+ }
239
+
240
+ unsubscribeEventBusListeners.push(pi.events.on(QUOTAS_CONFIG_UPDATED_EVENT, (data: unknown) => {
180
241
  const config = (data as QuotasConfigUpdatedPayload).config;
181
242
  enabled = config.usageStatus;
182
243
  deferToSynthetic = config.deferToSynthetic;
@@ -186,9 +247,9 @@ export default async function (pi: ExtensionAPI) {
186
247
  }
187
248
  if (currentContext) {
188
249
  refresher.start();
189
- void refresher.refreshFor(currentContext);
250
+ scheduleRefresh(currentContext);
190
251
  }
191
- });
252
+ }));
192
253
 
193
254
  /**
194
255
  * Whether to suppress our footer because pi-synthetic is showing
@@ -198,46 +259,55 @@ export default async function (pi: ExtensionAPI) {
198
259
  return deferToSynthetic && syntheticUsageActive && provider === "synthetic";
199
260
  }
200
261
 
201
- pi.on("session_start", async (_event, ctx) => {
262
+ pi.on("session_start", (_event, ctx) => {
202
263
  currentContext = ctx;
203
- if (!enabled) return;
204
- if (shouldDeferToSynthetic(ctx.model?.provider)) {
205
- ctx.ui.setStatus(EXTENSION_ID, undefined);
264
+ if (!enabled) {
265
+ refresher.stop(ctx);
266
+ return;
267
+ }
268
+ if (shouldDeferToSynthetic(getContextProvider(ctx))) {
269
+ refresher.stop(ctx);
206
270
  return;
207
271
  }
208
272
  refresher.start();
209
- await refresher.refreshFor(ctx);
273
+ scheduleRefresh(ctx);
210
274
  });
211
275
 
212
- pi.on("turn_end", async (_event, ctx) => {
276
+ pi.on("turn_end", (_event, ctx) => {
213
277
  currentContext = ctx;
214
278
  if (!enabled) return;
215
- if (shouldDeferToSynthetic(ctx.model?.provider)) return;
216
- await refresher.refreshFor(ctx);
279
+ if (shouldDeferToSynthetic(getContextProvider(ctx))) {
280
+ refresher.stop(ctx);
281
+ return;
282
+ }
283
+ scheduleRefresh(ctx);
217
284
  });
218
285
 
219
- pi.on("model_select", async (_event, ctx) => {
286
+ pi.on("model_select", (_event, ctx) => {
220
287
  currentContext = ctx;
221
288
  if (!enabled) {
222
289
  refresher.stop(ctx);
223
290
  return;
224
291
  }
225
- if (shouldDeferToSynthetic(ctx.model?.provider)) {
226
- ctx.ui.setStatus(EXTENSION_ID, undefined);
292
+ if (shouldDeferToSynthetic(getContextProvider(ctx))) {
293
+ refresher.stop(ctx);
227
294
  return;
228
295
  }
229
- await refresher.refreshFor(ctx);
296
+ scheduleRefresh(ctx);
230
297
  });
231
298
 
232
299
  pi.on("session_shutdown", async (_event, ctx) => {
233
300
  currentContext = undefined;
234
301
  syntheticUsageActive = false;
235
302
  refresher.stop(ctx);
303
+ for (const unsubscribe of unsubscribeEventBusListeners.splice(0)) {
304
+ unsubscribe();
305
+ }
236
306
  });
237
307
 
238
- pi.events.on(QUOTAS_EXTENSIONS_REQUEST_EVENT, () => {
308
+ unsubscribeEventBusListeners.push(pi.events.on(QUOTAS_EXTENSIONS_REQUEST_EVENT, () => {
239
309
  if (configLoader.getConfig().usageStatus) {
240
310
  pi.events.emit(QUOTAS_EXTENSIONS_REGISTER_EVENT, { feature: "usageStatus" });
241
311
  }
242
- });
312
+ }));
243
313
  }
package/src/lib/quotas.ts CHANGED
@@ -8,6 +8,8 @@ export const SUPPORTED_PROVIDERS: SupportedQuotaProvider[] = [
8
8
  "github-copilot",
9
9
  "openrouter",
10
10
  "synthetic",
11
+ "zai",
12
+ "opencode-go",
11
13
  ];
12
14
 
13
15
  export const PROVIDER_LABELS: Record<SupportedQuotaProvider, string> = {
@@ -16,6 +18,8 @@ export const PROVIDER_LABELS: Record<SupportedQuotaProvider, string> = {
16
18
  "github-copilot": "GitHub Copilot",
17
19
  openrouter: "OpenRouter",
18
20
  synthetic: "Synthetic",
21
+ zai: "Z.ai",
22
+ "opencode-go": "OpenCode Go",
19
23
  };
20
24
 
21
25
  const PROVIDER_TTLS_MS: Record<SupportedQuotaProvider, number> = {
@@ -24,6 +28,8 @@ const PROVIDER_TTLS_MS: Record<SupportedQuotaProvider, number> = {
24
28
  "github-copilot": 5 * 60_000,
25
29
  openrouter: 60_000,
26
30
  synthetic: 60_000,
31
+ zai: 60_000,
32
+ "opencode-go": 60_000,
27
33
  };
28
34
 
29
35
  type CacheEntry = {
@@ -54,7 +60,12 @@ export async function fetchProviderQuotas(
54
60
  const now = Date.now();
55
61
  const ttl = PROVIDER_TTLS_MS[provider];
56
62
 
57
- if (!options?.force && entry.result && entry.fetchedAt && now - entry.fetchedAt < ttl) {
63
+ if (
64
+ !options?.force &&
65
+ entry.result &&
66
+ entry.fetchedAt &&
67
+ now - entry.fetchedAt < ttl
68
+ ) {
58
69
  return entry.result;
59
70
  }
60
71
  if (!options?.force && entry.inFlight) return entry.inFlight;
@@ -0,0 +1,137 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ addBuckets,
4
+ emptyBuckets,
5
+ formatCost,
6
+ formatNumber,
7
+ formatTokenSummary,
8
+ type TokenBuckets,
9
+ } from "./session-tokens.js";
10
+
11
+ describe("emptyBuckets", () => {
12
+ it("returns all zeros", () => {
13
+ const b = emptyBuckets();
14
+ expect(b.input).toBe(0);
15
+ expect(b.output).toBe(0);
16
+ expect(b.cacheRead).toBe(0);
17
+ expect(b.cacheWrite).toBe(0);
18
+ expect(b.totalTokens).toBe(0);
19
+ expect(b.costTotal).toBe(0);
20
+ });
21
+ });
22
+
23
+ describe("addBuckets", () => {
24
+ it("sums two bucket sets", () => {
25
+ const a: TokenBuckets = {
26
+ input: 100,
27
+ output: 200,
28
+ cacheRead: 50,
29
+ cacheWrite: 25,
30
+ totalTokens: 375,
31
+ costTotal: 0.05,
32
+ };
33
+ const b: TokenBuckets = {
34
+ input: 300,
35
+ output: 400,
36
+ cacheRead: 100,
37
+ cacheWrite: 75,
38
+ totalTokens: 875,
39
+ costTotal: 0.15,
40
+ };
41
+ const result = addBuckets(a, b);
42
+ expect(result.input).toBe(400);
43
+ expect(result.output).toBe(600);
44
+ expect(result.cacheRead).toBe(150);
45
+ expect(result.cacheWrite).toBe(100);
46
+ expect(result.totalTokens).toBe(1250);
47
+ expect(result.costTotal).toBe(0.2);
48
+ });
49
+
50
+ it("handles empty buckets", () => {
51
+ const a = emptyBuckets();
52
+ const b: TokenBuckets = {
53
+ input: 100,
54
+ output: 0,
55
+ cacheRead: 0,
56
+ cacheWrite: 0,
57
+ totalTokens: 100,
58
+ costTotal: 0.01,
59
+ };
60
+ const result = addBuckets(a, b);
61
+ expect(result).toEqual(b);
62
+ });
63
+ });
64
+
65
+ describe("formatNumber", () => {
66
+ it("formats small numbers directly", () => {
67
+ expect(formatNumber(0)).toBe("0");
68
+ expect(formatNumber(42)).toBe("42");
69
+ expect(formatNumber(999)).toBe("999");
70
+ });
71
+
72
+ it("formats thousands with commas", () => {
73
+ expect(formatNumber(1000)).toBe("1,000");
74
+ expect(formatNumber(12345)).toBe("12.3K");
75
+ });
76
+
77
+ it("formats millions", () => {
78
+ expect(formatNumber(1_000_000)).toBe("1.0M");
79
+ expect(formatNumber(5_500_000)).toBe("5.5M");
80
+ });
81
+ });
82
+
83
+ describe("formatCost", () => {
84
+ it("formats zero", () => {
85
+ expect(formatCost(0)).toBe("$0.00");
86
+ });
87
+
88
+ it("formats small costs", () => {
89
+ expect(formatCost(0.005)).toBe("<$0.01");
90
+ expect(formatCost(0.01)).toBe("$0.01");
91
+ expect(formatCost(1.23)).toBe("$1.23");
92
+ });
93
+
94
+ it("formats large costs", () => {
95
+ expect(formatCost(1000)).toBe("$1.0K");
96
+ expect(formatCost(1234.56)).toBe("$1.2K");
97
+ });
98
+ });
99
+
100
+ describe("formatTokenSummary", () => {
101
+ it("formats full token breakdown", () => {
102
+ const tokens: TokenBuckets = {
103
+ input: 50000,
104
+ output: 10000,
105
+ cacheRead: 5000,
106
+ cacheWrite: 2000,
107
+ totalTokens: 67000,
108
+ costTotal: 0.45,
109
+ };
110
+ const result = formatTokenSummary(tokens);
111
+ expect(result).toContain("50.0K in");
112
+ expect(result).toContain("10.0K out");
113
+ expect(result).toContain("5,000 cached");
114
+ expect(result).toContain("$0.45");
115
+ });
116
+
117
+ it("omits zero fields", () => {
118
+ const tokens: TokenBuckets = {
119
+ input: 1000,
120
+ output: 500,
121
+ cacheRead: 0,
122
+ cacheWrite: 0,
123
+ totalTokens: 1500,
124
+ costTotal: 0,
125
+ };
126
+ const result = formatTokenSummary(tokens);
127
+ expect(result).toContain("in");
128
+ expect(result).toContain("out");
129
+ expect(result).not.toContain("cached");
130
+ expect(result).not.toContain("$");
131
+ });
132
+
133
+ it("handles empty buckets", () => {
134
+ const result = formatTokenSummary(emptyBuckets());
135
+ expect(result).toBe("");
136
+ });
137
+ });