@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.
- package/README.md +9 -2
- package/package.json +25 -5
- package/src/config.ts +43 -13
- package/src/extensions/command-quotas/command.ts +23 -1
- package/src/extensions/command-quotas/components/quotas-display.test.ts +29 -0
- package/src/extensions/command-quotas/components/quotas-display.ts +8 -2
- package/src/extensions/command-quotas/provider-commands.test.ts +9 -0
- package/src/extensions/command-quotas/provider-commands.ts +12 -0
- package/src/extensions/command-tokens/command.ts +102 -0
- package/src/extensions/command-tokens/index.ts +1 -0
- package/src/extensions/command-tokens/tokens-display.ts +357 -0
- package/src/extensions/quota-warnings/index.ts +36 -14
- package/src/extensions/token-status/index.ts +241 -0
- package/src/extensions/token-status/provider-detection.test.ts +30 -0
- package/src/extensions/usage-status/index.test.ts +182 -0
- package/src/extensions/usage-status/index.ts +104 -40
- package/src/lib/quotas.ts +12 -1
- package/src/lib/session-tokens.test.ts +137 -0
- package/src/lib/session-tokens.ts +399 -0
- package/src/providers/fetch.test.ts +31 -0
- package/src/providers/fetch.ts +167 -22
- package/src/providers/opencode-go-config.ts +127 -0
- package/src/providers/opencode-go.ts +183 -0
- package/src/providers/parse.test.ts +185 -7
- package/src/providers/providers.ts +229 -21
- package/src/types/quotas.ts +12 -3
- package/src/utils/quotas-severity.ts +14 -2
|
@@ -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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
113
|
-
} catch {
|
|
114
|
-
|
|
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(
|
|
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
|
|
187
|
+
activeProvider = getContextProvider(ctx);
|
|
188
|
+
generation++;
|
|
189
|
+
const requestGeneration = generation;
|
|
128
190
|
if (!activeProvider || !isSupportedProvider(activeProvider)) {
|
|
129
|
-
ctx
|
|
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
|
-
|
|
143
|
-
|
|
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
|
|
151
|
-
ctx
|
|
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
|
|
173
|
-
|
|
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)
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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
|
|
232
|
-
|
|
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 (
|
|
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
|
+
});
|