@latentminds/pi-quotas 0.2.6 → 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.
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ // Extract isGoProvider logic for testing
4
+ function isGoProvider(provider: string | undefined): boolean {
5
+ if (!provider) return false;
6
+ return provider === "opencode-go" || provider.startsWith("opencode-go/");
7
+ }
8
+
9
+ describe("isGoProvider", () => {
10
+ it("detects opencode-go provider", () => {
11
+ expect(isGoProvider("opencode-go")).toBe(true);
12
+ });
13
+
14
+ it("detects opencode-go/ prefixed providers", () => {
15
+ expect(isGoProvider("opencode-go/anthropic")).toBe(true);
16
+ expect(isGoProvider("opencode-go/openai")).toBe(true);
17
+ });
18
+
19
+ it("rejects non-go providers", () => {
20
+ expect(isGoProvider("opencode")).toBe(false);
21
+ expect(isGoProvider("anthropic")).toBe(false);
22
+ expect(isGoProvider("openai")).toBe(false);
23
+ expect(isGoProvider("github-copilot")).toBe(false);
24
+ });
25
+
26
+ it("rejects undefined/empty", () => {
27
+ expect(isGoProvider(undefined)).toBe(false);
28
+ expect(isGoProvider("")).toBe(false);
29
+ });
30
+ });
@@ -0,0 +1,182 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
3
+ import usageStatusExtension from "./index.js";
4
+ import { fetchProviderQuotas } from "../../lib/quotas.js";
5
+
6
+ vi.mock("../../config.js", () => ({
7
+ QUOTAS_CONFIG_UPDATED_EVENT: "quotas:config:updated",
8
+ QUOTAS_EXTENSIONS_REGISTER_EVENT: "quotas:extensions:register",
9
+ QUOTAS_EXTENSIONS_REQUEST_EVENT: "quotas:extensions:request",
10
+ configLoader: {
11
+ load: vi.fn(async () => undefined),
12
+ getConfig: vi.fn(() => ({
13
+ configVersion: "test",
14
+ quotasCommand: true,
15
+ providerCommands: true,
16
+ usageStatus: true,
17
+ quotaWarnings: true,
18
+ deferToSynthetic: true,
19
+ })),
20
+ },
21
+ }));
22
+
23
+ vi.mock("../../lib/quotas.js", () => ({
24
+ isSupportedProvider: (provider: string | undefined) => provider === "anthropic",
25
+ fetchProviderQuotas: vi.fn(async () => ({
26
+ success: true,
27
+ data: { provider: "anthropic", windows: [] },
28
+ })),
29
+ }));
30
+
31
+ const STALE_CONTEXT_ERROR =
32
+ "This extension ctx is stale after session replacement or reload.";
33
+
34
+ type EventHandler = (event: unknown, ctx: ExtensionContext) => unknown;
35
+
36
+ function createFakePi() {
37
+ const extensionHandlers = new Map<string, EventHandler[]>();
38
+ const eventBusHandlers = new Map<string, Array<(data: unknown) => void>>();
39
+
40
+ const pi = {
41
+ on(event: string, handler: EventHandler) {
42
+ const handlers = extensionHandlers.get(event) ?? [];
43
+ handlers.push(handler);
44
+ extensionHandlers.set(event, handlers);
45
+ },
46
+ events: {
47
+ on(channel: string, handler: (data: unknown) => void) {
48
+ const handlers = eventBusHandlers.get(channel) ?? [];
49
+ handlers.push(handler);
50
+ eventBusHandlers.set(channel, handlers);
51
+ return () => {
52
+ const current = eventBusHandlers.get(channel) ?? [];
53
+ eventBusHandlers.set(channel, current.filter((entry) => entry !== handler));
54
+ };
55
+ },
56
+ emit(channel: string, data: unknown) {
57
+ for (const handler of eventBusHandlers.get(channel) ?? []) handler(data);
58
+ },
59
+ },
60
+ } as unknown as ExtensionAPI;
61
+
62
+ return {
63
+ pi,
64
+ async emitExtensionEvent(event: string, ctx: ExtensionContext) {
65
+ for (const handler of extensionHandlers.get(event) ?? []) {
66
+ await handler({ type: event, reason: "test" }, ctx);
67
+ }
68
+ },
69
+ emitBusEvent(channel: string, data: unknown) {
70
+ pi.events.emit(channel, data);
71
+ },
72
+ listenerCount(channel: string) {
73
+ return eventBusHandlers.get(channel)?.length ?? 0;
74
+ },
75
+ };
76
+ }
77
+
78
+ function createContext(provider: string) {
79
+ let stale = false;
80
+ const setStatus = vi.fn(() => {
81
+ if (stale) throw new Error(STALE_CONTEXT_ERROR);
82
+ });
83
+
84
+ const ctx = {
85
+ get hasUI() {
86
+ if (stale) throw new Error(STALE_CONTEXT_ERROR);
87
+ return true;
88
+ },
89
+ get model() {
90
+ if (stale) throw new Error(STALE_CONTEXT_ERROR);
91
+ return { provider };
92
+ },
93
+ modelRegistry: { authStorage: {} },
94
+ ui: {
95
+ theme: { fg: (_color: string, text: string) => text },
96
+ setStatus,
97
+ },
98
+ } as unknown as ExtensionContext;
99
+
100
+ return {
101
+ ctx,
102
+ setStale() {
103
+ stale = true;
104
+ },
105
+ setStatus,
106
+ };
107
+ }
108
+
109
+ afterEach(() => {
110
+ vi.useRealTimers();
111
+ vi.clearAllMocks();
112
+ });
113
+
114
+ describe("usage-status extension lifecycle", () => {
115
+ it("ignores interval refreshes for stale session contexts", async () => {
116
+ vi.useFakeTimers();
117
+ const { pi, emitExtensionEvent } = createFakePi();
118
+ const { ctx, setStale } = createContext("unsupported-provider");
119
+
120
+ await usageStatusExtension(pi);
121
+ await emitExtensionEvent("session_start", ctx);
122
+
123
+ setStale();
124
+
125
+ expect(() => vi.advanceTimersByTime(60_000)).not.toThrow();
126
+ await vi.runOnlyPendingTimersAsync();
127
+ });
128
+
129
+ it("does not throw when event-bus callbacks see a stale session context", async () => {
130
+ const { pi, emitExtensionEvent, emitBusEvent } = createFakePi();
131
+ const { ctx, setStale } = createContext("synthetic");
132
+
133
+ await usageStatusExtension(pi);
134
+ await emitExtensionEvent("session_start", ctx);
135
+
136
+ setStale();
137
+
138
+ expect(() => {
139
+ emitBusEvent("synthetic:extensions:register", { feature: "usageStatus" });
140
+ emitBusEvent("quotas:config:updated", {
141
+ config: { usageStatus: true, deferToSynthetic: true },
142
+ });
143
+ }).not.toThrow();
144
+ });
145
+
146
+ it("unsubscribes event-bus listeners during session shutdown", async () => {
147
+ const { pi, emitExtensionEvent, listenerCount } = createFakePi();
148
+ const { ctx } = createContext("unsupported-provider");
149
+
150
+ await usageStatusExtension(pi);
151
+ expect(listenerCount("quotas:config:updated")).toBe(1);
152
+ expect(listenerCount("synthetic:extensions:register")).toBe(1);
153
+ expect(listenerCount("quotas:extensions:request")).toBe(1);
154
+
155
+ await emitExtensionEvent("session_shutdown", ctx);
156
+
157
+ expect(listenerCount("quotas:config:updated")).toBe(0);
158
+ expect(listenerCount("synthetic:extensions:register")).toBe(0);
159
+ expect(listenerCount("quotas:extensions:request")).toBe(0);
160
+ });
161
+
162
+ it("clears the footer silently for not_applicable credentials instead of warning", async () => {
163
+ vi.useFakeTimers();
164
+ vi.mocked(fetchProviderQuotas).mockResolvedValueOnce({
165
+ success: false,
166
+ error: { kind: "not_applicable", message: "Direct API key" },
167
+ } as any);
168
+
169
+ const { pi, emitExtensionEvent } = createFakePi();
170
+ const { ctx, setStatus } = createContext("anthropic");
171
+
172
+ await usageStatusExtension(pi);
173
+ await emitExtensionEvent("session_start", ctx);
174
+ await vi.runOnlyPendingTimersAsync();
175
+ await vi.advanceTimersByTimeAsync(0);
176
+
177
+ const calls = setStatus.mock.calls as unknown as Array<[string, string | undefined]>;
178
+ const last = calls[calls.length - 1]?.[1];
179
+ expect(last).toBeUndefined();
180
+ expect(calls.some((c) => c[1] === "usage unavailable")).toBe(false);
181
+ });
182
+ });
@@ -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,25 +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
236
  function scheduleRefresh(ctx: ExtensionContext): void {
180
- void refresher.refreshFor(ctx).catch(() => {
181
- if (ctx.hasUI) ctx.ui.setStatus(EXTENSION_ID, ctx.ui.theme.fg("warning", "usage unavailable"));
182
- });
237
+ void refresher.refreshFor(ctx).catch(() => undefined);
183
238
  }
184
239
 
185
- pi.events.on(QUOTAS_CONFIG_UPDATED_EVENT, (data: unknown) => {
240
+ unsubscribeEventBusListeners.push(pi.events.on(QUOTAS_CONFIG_UPDATED_EVENT, (data: unknown) => {
186
241
  const config = (data as QuotasConfigUpdatedPayload).config;
187
242
  enabled = config.usageStatus;
188
243
  deferToSynthetic = config.deferToSynthetic;
@@ -194,7 +249,7 @@ export default async function (pi: ExtensionAPI) {
194
249
  refresher.start();
195
250
  scheduleRefresh(currentContext);
196
251
  }
197
- });
252
+ }));
198
253
 
199
254
  /**
200
255
  * Whether to suppress our footer because pi-synthetic is showing
@@ -206,9 +261,12 @@ export default async function (pi: ExtensionAPI) {
206
261
 
207
262
  pi.on("session_start", (_event, ctx) => {
208
263
  currentContext = ctx;
209
- if (!enabled) return;
210
- if (shouldDeferToSynthetic(ctx.model?.provider)) {
211
- 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);
212
270
  return;
213
271
  }
214
272
  refresher.start();
@@ -218,7 +276,10 @@ export default async function (pi: ExtensionAPI) {
218
276
  pi.on("turn_end", (_event, ctx) => {
219
277
  currentContext = ctx;
220
278
  if (!enabled) return;
221
- if (shouldDeferToSynthetic(ctx.model?.provider)) return;
279
+ if (shouldDeferToSynthetic(getContextProvider(ctx))) {
280
+ refresher.stop(ctx);
281
+ return;
282
+ }
222
283
  scheduleRefresh(ctx);
223
284
  });
224
285
 
@@ -228,8 +289,8 @@ export default async function (pi: ExtensionAPI) {
228
289
  refresher.stop(ctx);
229
290
  return;
230
291
  }
231
- if (shouldDeferToSynthetic(ctx.model?.provider)) {
232
- ctx.ui.setStatus(EXTENSION_ID, undefined);
292
+ if (shouldDeferToSynthetic(getContextProvider(ctx))) {
293
+ refresher.stop(ctx);
233
294
  return;
234
295
  }
235
296
  scheduleRefresh(ctx);
@@ -239,11 +300,14 @@ export default async function (pi: ExtensionAPI) {
239
300
  currentContext = undefined;
240
301
  syntheticUsageActive = false;
241
302
  refresher.stop(ctx);
303
+ for (const unsubscribe of unsubscribeEventBusListeners.splice(0)) {
304
+ unsubscribe();
305
+ }
242
306
  });
243
307
 
244
- pi.events.on(QUOTAS_EXTENSIONS_REQUEST_EVENT, () => {
308
+ unsubscribeEventBusListeners.push(pi.events.on(QUOTAS_EXTENSIONS_REQUEST_EVENT, () => {
245
309
  if (configLoader.getConfig().usageStatus) {
246
310
  pi.events.emit(QUOTAS_EXTENSIONS_REGISTER_EVENT, { feature: "usageStatus" });
247
311
  }
248
- });
312
+ }));
249
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
+ });