@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.
- package/README.md +34 -23
- 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 +154 -0
- package/src/extensions/command-quotas/components/quotas-display.ts +13 -10
- 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 +47 -19
- 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 +114 -44
- 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 +71 -0
- package/src/providers/fetch.ts +200 -17
- 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.test.ts +22 -2
- package/src/utils/quotas-severity.ts +28 -6
|
@@ -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,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
|
|
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 {
|
|
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
|
-
|
|
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",
|
|
262
|
+
pi.on("session_start", (_event, ctx) => {
|
|
202
263
|
currentContext = ctx;
|
|
203
|
-
if (!enabled)
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
273
|
+
scheduleRefresh(ctx);
|
|
210
274
|
});
|
|
211
275
|
|
|
212
|
-
pi.on("turn_end",
|
|
276
|
+
pi.on("turn_end", (_event, ctx) => {
|
|
213
277
|
currentContext = ctx;
|
|
214
278
|
if (!enabled) return;
|
|
215
|
-
if (shouldDeferToSynthetic(ctx
|
|
216
|
-
|
|
279
|
+
if (shouldDeferToSynthetic(getContextProvider(ctx))) {
|
|
280
|
+
refresher.stop(ctx);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
scheduleRefresh(ctx);
|
|
217
284
|
});
|
|
218
285
|
|
|
219
|
-
pi.on("model_select",
|
|
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
|
|
226
|
-
|
|
292
|
+
if (shouldDeferToSynthetic(getContextProvider(ctx))) {
|
|
293
|
+
refresher.stop(ctx);
|
|
227
294
|
return;
|
|
228
295
|
}
|
|
229
|
-
|
|
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 (
|
|
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
|
+
});
|