@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.
@@ -0,0 +1,241 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ ThemeColor,
5
+ } from "@mariozechner/pi-coding-agent";
6
+ import {
7
+ QUOTAS_CONFIG_UPDATED_EVENT,
8
+ QUOTAS_EXTENSIONS_REGISTER_EVENT,
9
+ QUOTAS_EXTENSIONS_REQUEST_EVENT,
10
+ type QuotasConfigUpdatedPayload,
11
+ configLoader,
12
+ } from "../../config.js";
13
+ import { aggregateAllSessions, formatCost } from "../../lib/session-tokens.js";
14
+
15
+ const EXTENSION_ID = "pi-quotas-token-status";
16
+ const REFRESH_INTERVAL_MS = 60_000;
17
+
18
+ /** Go tier limits (approximate, from docs) */
19
+ const GO_LIMITS = {
20
+ rolling5h: 12, // $12 per 5 hours
21
+ weekly: 30, // $30 per week
22
+ monthly: 60, // $60 per month
23
+ } as const;
24
+
25
+ interface RollingWindowCosts {
26
+ rolling5h: number;
27
+ weekly: number;
28
+ monthly: number;
29
+ }
30
+
31
+ function computeWindowTimestamps(now: Date): {
32
+ since5h: Date;
33
+ sinceWeekly: Date;
34
+ sinceMonthly: Date;
35
+ } {
36
+ return {
37
+ since5h: new Date(now.getTime() - 5 * 60 * 60 * 1000),
38
+ sinceWeekly: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
39
+ sinceMonthly: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
40
+ };
41
+ }
42
+
43
+ async function computeRollingCosts(cwd?: string): Promise<RollingWindowCosts> {
44
+ const now = new Date();
45
+ const { since5h, sinceWeekly, sinceMonthly } = computeWindowTimestamps(now);
46
+
47
+ // Run all three aggregations in parallel
48
+ const [result5h, resultWeekly, resultMonthly] = await Promise.all([
49
+ aggregateAllSessions({ cwd, since: since5h }),
50
+ aggregateAllSessions({ cwd, since: sinceWeekly }),
51
+ aggregateAllSessions({ cwd, since: sinceMonthly }),
52
+ ]);
53
+
54
+ return {
55
+ rolling5h: result5h.totals.costTotal,
56
+ weekly: resultWeekly.totals.costTotal,
57
+ monthly: resultMonthly.totals.costTotal,
58
+ };
59
+ }
60
+
61
+ function formatWindowForStatus(
62
+ theme: ExtensionContext["ui"]["theme"],
63
+ label: string,
64
+ cost: number,
65
+ limit: number,
66
+ ): string {
67
+ const percent = limit > 0 ? Math.round((cost / limit) * 100) : 0;
68
+ const costStr = formatCost(cost);
69
+
70
+ // Color by usage level
71
+ let color: ThemeColor;
72
+ if (percent >= 90) {
73
+ color = "error";
74
+ } else if (percent >= 70) {
75
+ color = "warning";
76
+ } else {
77
+ color = "dim";
78
+ }
79
+
80
+ return `${theme.fg(color, `${label}:`)}${theme.fg("accent", costStr)}`;
81
+ }
82
+
83
+ function formatTokenStatus(
84
+ theme: ExtensionContext["ui"]["theme"],
85
+ costs: RollingWindowCosts,
86
+ ): string {
87
+ const parts = [
88
+ formatWindowForStatus(theme, "5h", costs.rolling5h, GO_LIMITS.rolling5h),
89
+ formatWindowForStatus(theme, "wk", costs.weekly, GO_LIMITS.weekly),
90
+ formatWindowForStatus(theme, "mo", costs.monthly, GO_LIMITS.monthly),
91
+ ];
92
+ return parts.join(" · ");
93
+ }
94
+
95
+ function isGoProvider(provider: string | undefined): boolean {
96
+ if (!provider) return false;
97
+ return provider === "opencode-go" || provider.startsWith("opencode-go/");
98
+ }
99
+
100
+ function createTokenStatusRefresher() {
101
+ let refreshTimer: ReturnType<typeof setInterval> | undefined;
102
+ let activeContext: ExtensionContext | undefined;
103
+ let lastCosts: RollingWindowCosts | undefined;
104
+ let inFlight = false;
105
+ let queued = false;
106
+
107
+ async function update(ctx: ExtensionContext): Promise<void> {
108
+ if (!ctx.hasUI) return;
109
+ if (inFlight) {
110
+ queued = true;
111
+ return;
112
+ }
113
+ inFlight = true;
114
+ try {
115
+ const costs = await computeRollingCosts(ctx.cwd);
116
+ if (!ctx.hasUI) return;
117
+ lastCosts = costs;
118
+ const status = formatTokenStatus(ctx.ui.theme, costs);
119
+ ctx.ui.setStatus(EXTENSION_ID, status);
120
+ } catch {
121
+ ctx.ui.setStatus(
122
+ EXTENSION_ID,
123
+ ctx.ui.theme.fg("warning", "token tracking unavailable"),
124
+ );
125
+ } finally {
126
+ inFlight = false;
127
+ if (queued) {
128
+ queued = false;
129
+ void update(ctx);
130
+ }
131
+ }
132
+ }
133
+
134
+ return {
135
+ async refreshFor(ctx: ExtensionContext): Promise<void> {
136
+ activeContext = ctx;
137
+ if (!isGoProvider(ctx.model?.provider)) {
138
+ ctx.ui.setStatus(EXTENSION_ID, undefined);
139
+ return;
140
+ }
141
+ await update(ctx);
142
+ },
143
+ start(): void {
144
+ if (refreshTimer) clearInterval(refreshTimer);
145
+ refreshTimer = setInterval(() => {
146
+ if (activeContext) void update(activeContext);
147
+ }, REFRESH_INTERVAL_MS);
148
+ refreshTimer.unref?.();
149
+ },
150
+ stop(ctx?: ExtensionContext): void {
151
+ if (refreshTimer) clearInterval(refreshTimer);
152
+ refreshTimer = undefined;
153
+ activeContext = undefined;
154
+ lastCosts = undefined;
155
+ ctx?.ui.setStatus(EXTENSION_ID, undefined);
156
+ },
157
+ renderLast(ctx: ExtensionContext): boolean {
158
+ if (!lastCosts || !ctx.hasUI) return false;
159
+ ctx.ui.setStatus(
160
+ EXTENSION_ID,
161
+ formatTokenStatus(ctx.ui.theme, lastCosts),
162
+ );
163
+ return true;
164
+ },
165
+ };
166
+ }
167
+
168
+ export default async function (pi: ExtensionAPI) {
169
+ await configLoader.load();
170
+ const refresher = createTokenStatusRefresher();
171
+ let enabled = configLoader.getConfig().tokenStatus;
172
+ let currentContext: ExtensionContext | undefined;
173
+
174
+ function scheduleRefresh(ctx: ExtensionContext): void {
175
+ void refresher.refreshFor(ctx).catch(() => {
176
+ if (ctx.hasUI)
177
+ ctx.ui.setStatus(
178
+ EXTENSION_ID,
179
+ ctx.ui.theme.fg("warning", "token tracking unavailable"),
180
+ );
181
+ });
182
+ }
183
+
184
+ pi.events.on(QUOTAS_CONFIG_UPDATED_EVENT, (data: unknown) => {
185
+ const config = (data as QuotasConfigUpdatedPayload).config;
186
+ enabled = config.tokenStatus;
187
+ if (!enabled) {
188
+ refresher.stop(currentContext);
189
+ return;
190
+ }
191
+ if (currentContext) {
192
+ refresher.start();
193
+ scheduleRefresh(currentContext);
194
+ }
195
+ });
196
+
197
+ pi.on("session_start", (_event, ctx) => {
198
+ currentContext = ctx;
199
+ if (!enabled) return;
200
+ if (!isGoProvider(ctx.model?.provider)) {
201
+ ctx.ui.setStatus(EXTENSION_ID, undefined);
202
+ return;
203
+ }
204
+ refresher.start();
205
+ scheduleRefresh(ctx);
206
+ });
207
+
208
+ pi.on("turn_end", (_event, ctx) => {
209
+ currentContext = ctx;
210
+ if (!enabled) return;
211
+ if (!isGoProvider(ctx.model?.provider)) return;
212
+ scheduleRefresh(ctx);
213
+ });
214
+
215
+ pi.on("model_select", (_event, ctx) => {
216
+ currentContext = ctx;
217
+ if (!enabled) {
218
+ refresher.stop(ctx);
219
+ return;
220
+ }
221
+ if (!isGoProvider(ctx.model?.provider)) {
222
+ refresher.stop(ctx);
223
+ return;
224
+ }
225
+ refresher.start();
226
+ scheduleRefresh(ctx);
227
+ });
228
+
229
+ pi.on("session_shutdown", async (_event, ctx) => {
230
+ currentContext = undefined;
231
+ refresher.stop(ctx);
232
+ });
233
+
234
+ pi.events.on(QUOTAS_EXTENSIONS_REQUEST_EVENT, () => {
235
+ if (configLoader.getConfig().tokenStatus) {
236
+ pi.events.emit(QUOTAS_EXTENSIONS_REGISTER_EVENT, {
237
+ feature: "tokenStatus",
238
+ });
239
+ }
240
+ });
241
+ }
@@ -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
+ });