pi-free 1.0.0
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/.github/workflows/update-benchmarks.yml +67 -0
- package/.pi/skills/pi-extension-dev/SKILL.md +155 -0
- package/CHANGELOG.md +59 -0
- package/LICENSE +21 -0
- package/README.md +289 -0
- package/config.ts +224 -0
- package/constants.ts +110 -0
- package/docs/free-tier-limits.md +213 -0
- package/docs/model-hopping.md +214 -0
- package/docs/plans/file-reorganization.md +172 -0
- package/docs/plans/package-json-fix.md +143 -0
- package/docs/provider-failover-plan.md +279 -0
- package/lib/json-persistence.ts +102 -0
- package/lib/logger.ts +94 -0
- package/lib/model-enhancer.ts +20 -0
- package/lib/types.ts +108 -0
- package/lib/util.ts +256 -0
- package/package.json +52 -0
- package/provider-factory.ts +221 -0
- package/provider-failover/errors.ts +275 -0
- package/provider-failover/hardcoded-benchmarks.ts +9889 -0
- package/provider-failover/index.ts +194 -0
- package/provider-helper.ts +336 -0
- package/providers/cline-auth.ts +473 -0
- package/providers/cline-models.ts +77 -0
- package/providers/cline.ts +257 -0
- package/providers/factory.ts +125 -0
- package/providers/fireworks.ts +49 -0
- package/providers/kilo-auth.ts +172 -0
- package/providers/kilo-models.ts +26 -0
- package/providers/kilo.ts +144 -0
- package/providers/mistral.ts +144 -0
- package/providers/model-fetcher.ts +138 -0
- package/providers/nvidia.ts +97 -0
- package/providers/ollama.ts +113 -0
- package/providers/openrouter.ts +175 -0
- package/providers/zen.ts +416 -0
- package/scripts/update-benchmarks.ts +255 -0
- package/tests/cline.test.ts +149 -0
- package/tests/errors.test.ts +139 -0
- package/tests/failover.test.ts +94 -0
- package/tests/fireworks.test.ts +148 -0
- package/tests/free-tier-limits.test.ts +191 -0
- package/tests/json-persistence.test.ts +105 -0
- package/tests/kilo.test.ts +186 -0
- package/tests/mistral.test.ts +138 -0
- package/tests/nvidia.test.ts +55 -0
- package/tests/ollama.test.ts +261 -0
- package/tests/openrouter.test.ts +192 -0
- package/tests/usage-tracking.test.ts +150 -0
- package/tests/util.test.ts +413 -0
- package/tests/zen.test.ts +180 -0
- package/todo.md +153 -0
- package/tsconfig.json +26 -0
- package/usage/commands.ts +17 -0
- package/usage/cumulative.ts +193 -0
- package/usage/formatters.ts +131 -0
- package/usage/index.ts +46 -0
- package/usage/limits.ts +166 -0
- package/usage/metrics.ts +222 -0
- package/usage/sessions.ts +355 -0
- package/usage/store.ts +99 -0
- package/usage/tracking.ts +329 -0
- package/usage/widget.ts +90 -0
- package/vitest.config.ts +20 -0
- package/widget/data.ts +113 -0
- package/widget/format.ts +26 -0
- package/widget/render.ts +117 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage tracking - runtime session and model-level tracking
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createLogger } from "../lib/logger.ts";
|
|
6
|
+
import { persistUsage } from "./cumulative.ts";
|
|
7
|
+
import { incrementRequestCount } from "./metrics.ts";
|
|
8
|
+
|
|
9
|
+
const logger = createLogger("usage:tracking");
|
|
10
|
+
|
|
11
|
+
export interface ModelUsageEntry {
|
|
12
|
+
count: number;
|
|
13
|
+
tokensIn: number;
|
|
14
|
+
tokensOut: number;
|
|
15
|
+
cacheRead: number;
|
|
16
|
+
cacheWrite: number;
|
|
17
|
+
cost: number;
|
|
18
|
+
lastUsed: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface SessionStats {
|
|
22
|
+
startTime: number;
|
|
23
|
+
providers: Map<
|
|
24
|
+
string,
|
|
25
|
+
{
|
|
26
|
+
requests: number;
|
|
27
|
+
tokensIn: number;
|
|
28
|
+
tokensOut: number;
|
|
29
|
+
models: Map<string, ModelUsageEntry>;
|
|
30
|
+
}
|
|
31
|
+
>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Runtime tracking state
|
|
35
|
+
const modelUsageCounts = new Map<string, ModelUsageEntry>();
|
|
36
|
+
const sessionStats: SessionStats = {
|
|
37
|
+
startTime: Date.now(),
|
|
38
|
+
providers: new Map(),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function resetUsageStats(): void {
|
|
42
|
+
modelUsageCounts.clear();
|
|
43
|
+
sessionStats.startTime = Date.now();
|
|
44
|
+
sessionStats.providers.clear();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function incrementModelRequestCount(
|
|
48
|
+
provider: string,
|
|
49
|
+
modelId: string,
|
|
50
|
+
tokensIn = 0,
|
|
51
|
+
tokensOut = 0,
|
|
52
|
+
cacheRead = 0,
|
|
53
|
+
cacheWrite = 0,
|
|
54
|
+
cost = 0,
|
|
55
|
+
): void {
|
|
56
|
+
const key = `${provider}/${modelId}`;
|
|
57
|
+
const existing = modelUsageCounts.get(key);
|
|
58
|
+
|
|
59
|
+
if (existing) {
|
|
60
|
+
existing.count++;
|
|
61
|
+
existing.tokensIn += tokensIn;
|
|
62
|
+
existing.tokensOut += tokensOut;
|
|
63
|
+
existing.cacheRead += cacheRead;
|
|
64
|
+
existing.cacheWrite += cacheWrite;
|
|
65
|
+
existing.cost += cost;
|
|
66
|
+
existing.lastUsed = Date.now();
|
|
67
|
+
} else {
|
|
68
|
+
modelUsageCounts.set(key, {
|
|
69
|
+
count: 1,
|
|
70
|
+
tokensIn,
|
|
71
|
+
tokensOut,
|
|
72
|
+
cacheRead,
|
|
73
|
+
cacheWrite,
|
|
74
|
+
cost,
|
|
75
|
+
lastUsed: Date.now(),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
incrementRequestCount(provider);
|
|
80
|
+
|
|
81
|
+
// Track in session stats
|
|
82
|
+
let providerStats = sessionStats.providers.get(provider);
|
|
83
|
+
if (!providerStats) {
|
|
84
|
+
providerStats = {
|
|
85
|
+
requests: 0,
|
|
86
|
+
tokensIn: 0,
|
|
87
|
+
tokensOut: 0,
|
|
88
|
+
models: new Map(),
|
|
89
|
+
};
|
|
90
|
+
sessionStats.providers.set(provider, providerStats);
|
|
91
|
+
}
|
|
92
|
+
providerStats.requests++;
|
|
93
|
+
providerStats.tokensIn += tokensIn;
|
|
94
|
+
providerStats.tokensOut += tokensOut;
|
|
95
|
+
|
|
96
|
+
const modelStats = providerStats.models.get(modelId);
|
|
97
|
+
if (modelStats) {
|
|
98
|
+
modelStats.count++;
|
|
99
|
+
modelStats.tokensIn += tokensIn;
|
|
100
|
+
modelStats.tokensOut += tokensOut;
|
|
101
|
+
modelStats.lastUsed = Date.now();
|
|
102
|
+
} else {
|
|
103
|
+
providerStats.models.set(modelId, {
|
|
104
|
+
count: 1,
|
|
105
|
+
tokensIn,
|
|
106
|
+
tokensOut,
|
|
107
|
+
cacheRead: 0,
|
|
108
|
+
cacheWrite: 0,
|
|
109
|
+
cost: 0,
|
|
110
|
+
lastUsed: Date.now(),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Persist to disk
|
|
115
|
+
persistUsage(
|
|
116
|
+
provider,
|
|
117
|
+
modelId,
|
|
118
|
+
tokensIn,
|
|
119
|
+
tokensOut,
|
|
120
|
+
cacheRead,
|
|
121
|
+
cacheWrite,
|
|
122
|
+
cost,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function getModelUsage(
|
|
127
|
+
provider: string,
|
|
128
|
+
modelId: string,
|
|
129
|
+
): ModelUsageEntry | undefined {
|
|
130
|
+
return modelUsageCounts.get(`${provider}/${modelId}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function getProviderModelUsage(provider: string): Array<{
|
|
134
|
+
modelId: string;
|
|
135
|
+
count: number;
|
|
136
|
+
tokensIn: number;
|
|
137
|
+
tokensOut: number;
|
|
138
|
+
lastUsed: number;
|
|
139
|
+
}> {
|
|
140
|
+
const results: Array<{
|
|
141
|
+
modelId: string;
|
|
142
|
+
count: number;
|
|
143
|
+
tokensIn: number;
|
|
144
|
+
tokensOut: number;
|
|
145
|
+
lastUsed: number;
|
|
146
|
+
}> = [];
|
|
147
|
+
const prefix = `${provider}/`;
|
|
148
|
+
|
|
149
|
+
for (const [key, entry] of modelUsageCounts.entries()) {
|
|
150
|
+
if (key.startsWith(prefix)) {
|
|
151
|
+
results.push({
|
|
152
|
+
modelId: key.slice(prefix.length),
|
|
153
|
+
count: entry.count,
|
|
154
|
+
tokensIn: entry.tokensIn,
|
|
155
|
+
tokensOut: entry.tokensOut,
|
|
156
|
+
lastUsed: entry.lastUsed,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return results.sort((a, b) => b.count - a.count);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function getTopModels(n = 10): Array<{
|
|
165
|
+
provider: string;
|
|
166
|
+
modelId: string;
|
|
167
|
+
count: number;
|
|
168
|
+
tokensIn: number;
|
|
169
|
+
tokensOut: number;
|
|
170
|
+
}> {
|
|
171
|
+
const all: Array<{
|
|
172
|
+
provider: string;
|
|
173
|
+
modelId: string;
|
|
174
|
+
count: number;
|
|
175
|
+
tokensIn: number;
|
|
176
|
+
tokensOut: number;
|
|
177
|
+
}> = [];
|
|
178
|
+
|
|
179
|
+
for (const [key, entry] of modelUsageCounts.entries()) {
|
|
180
|
+
const slashIndex = key.indexOf("/");
|
|
181
|
+
const provider = key.slice(0, slashIndex);
|
|
182
|
+
const modelId = key.slice(slashIndex + 1);
|
|
183
|
+
all.push({
|
|
184
|
+
provider,
|
|
185
|
+
modelId,
|
|
186
|
+
count: entry.count,
|
|
187
|
+
tokensIn: entry.tokensIn,
|
|
188
|
+
tokensOut: entry.tokensOut,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return all.sort((a, b) => b.count - a.count).slice(0, n);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function logModelUsageReport(provider?: string): void {
|
|
196
|
+
if (provider) {
|
|
197
|
+
const models = getProviderModelUsage(provider);
|
|
198
|
+
const total = models.reduce((sum, m) => sum + m.count, 0);
|
|
199
|
+
const totalTokensIn = models.reduce((sum, m) => sum + m.tokensIn, 0);
|
|
200
|
+
const totalTokensOut = models.reduce((sum, m) => sum + m.tokensOut, 0);
|
|
201
|
+
|
|
202
|
+
logger.info(`${provider} usage summary: ${total} total requests`, {
|
|
203
|
+
total,
|
|
204
|
+
tokensInK: Math.round(totalTokensIn / 1000),
|
|
205
|
+
tokensOutK: Math.round(totalTokensOut / 1000),
|
|
206
|
+
});
|
|
207
|
+
for (const m of models.slice(0, 5)) {
|
|
208
|
+
logger.debug(`${m.modelId} stats: ${m.count} requests`, {
|
|
209
|
+
modelId: m.modelId,
|
|
210
|
+
count: m.count,
|
|
211
|
+
tokensInK: Math.round(m.tokensIn / 1000),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
logger.info("Top 10 models across all providers");
|
|
216
|
+
for (const m of getTopModels(10)) {
|
|
217
|
+
logger.debug(`${m.provider}/${m.modelId}: ${m.count} requests`, {
|
|
218
|
+
provider: m.provider,
|
|
219
|
+
modelId: m.modelId,
|
|
220
|
+
count: m.count,
|
|
221
|
+
tokensInK: Math.round(m.tokensIn / 1000),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface SessionUsageReport {
|
|
228
|
+
duration: number;
|
|
229
|
+
durationFormatted: string;
|
|
230
|
+
providers: Array<{
|
|
231
|
+
name: string;
|
|
232
|
+
requests: number;
|
|
233
|
+
tokensIn: number;
|
|
234
|
+
tokensOut: number;
|
|
235
|
+
cacheRead: number;
|
|
236
|
+
cacheWrite: number;
|
|
237
|
+
cost: number;
|
|
238
|
+
topModels: Array<{
|
|
239
|
+
modelId: string;
|
|
240
|
+
count: number;
|
|
241
|
+
tokensIn: number;
|
|
242
|
+
tokensOut: number;
|
|
243
|
+
cacheRead: number;
|
|
244
|
+
cacheWrite: number;
|
|
245
|
+
cost: number;
|
|
246
|
+
}>;
|
|
247
|
+
}>;
|
|
248
|
+
totalRequests: number;
|
|
249
|
+
totalTokensIn: number;
|
|
250
|
+
totalTokensOut: number;
|
|
251
|
+
totalCacheRead: number;
|
|
252
|
+
totalCacheWrite: number;
|
|
253
|
+
totalCost: number;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function formatDuration(ms: number): string {
|
|
257
|
+
const seconds = Math.floor(ms / 1000);
|
|
258
|
+
const minutes = Math.floor(seconds / 60);
|
|
259
|
+
const hours = Math.floor(minutes / 60);
|
|
260
|
+
|
|
261
|
+
if (hours > 0) return `${hours}h ${minutes % 60}m`;
|
|
262
|
+
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
|
263
|
+
return `${seconds}s`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function getSessionUsage(): SessionUsageReport {
|
|
267
|
+
const now = Date.now();
|
|
268
|
+
const duration = now - sessionStats.startTime;
|
|
269
|
+
|
|
270
|
+
const providers: SessionUsageReport["providers"] = [];
|
|
271
|
+
let totalRequests = 0;
|
|
272
|
+
let totalTokensIn = 0;
|
|
273
|
+
let totalTokensOut = 0;
|
|
274
|
+
let totalCacheRead = 0;
|
|
275
|
+
let totalCacheWrite = 0;
|
|
276
|
+
let totalCost = 0;
|
|
277
|
+
|
|
278
|
+
for (const [providerName, stats] of sessionStats.providers) {
|
|
279
|
+
totalRequests += stats.requests;
|
|
280
|
+
totalTokensIn += stats.tokensIn;
|
|
281
|
+
totalTokensOut += stats.tokensOut;
|
|
282
|
+
|
|
283
|
+
const topModels = Array.from(stats.models.entries())
|
|
284
|
+
.map(([modelId, m]) => ({
|
|
285
|
+
modelId,
|
|
286
|
+
count: m.count,
|
|
287
|
+
tokensIn: m.tokensIn,
|
|
288
|
+
tokensOut: m.tokensOut,
|
|
289
|
+
cacheRead: m.cacheRead,
|
|
290
|
+
cacheWrite: m.cacheWrite,
|
|
291
|
+
cost: m.cost,
|
|
292
|
+
}))
|
|
293
|
+
.sort((a, b) => b.count - a.count)
|
|
294
|
+
.slice(0, 5);
|
|
295
|
+
|
|
296
|
+
// Sum cache and cost from models
|
|
297
|
+
const providerCacheRead = topModels.reduce((s, m) => s + m.cacheRead, 0);
|
|
298
|
+
const providerCacheWrite = topModels.reduce((s, m) => s + m.cacheWrite, 0);
|
|
299
|
+
const providerCost = topModels.reduce((s, m) => s + m.cost, 0);
|
|
300
|
+
totalCacheRead += providerCacheRead;
|
|
301
|
+
totalCacheWrite += providerCacheWrite;
|
|
302
|
+
totalCost += providerCost;
|
|
303
|
+
|
|
304
|
+
providers.push({
|
|
305
|
+
name: providerName,
|
|
306
|
+
requests: stats.requests,
|
|
307
|
+
tokensIn: stats.tokensIn,
|
|
308
|
+
tokensOut: stats.tokensOut,
|
|
309
|
+
cacheRead: providerCacheRead,
|
|
310
|
+
cacheWrite: providerCacheWrite,
|
|
311
|
+
cost: providerCost,
|
|
312
|
+
topModels,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
providers.sort((a, b) => b.requests - a.requests);
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
duration,
|
|
320
|
+
durationFormatted: formatDuration(duration),
|
|
321
|
+
providers,
|
|
322
|
+
totalRequests,
|
|
323
|
+
totalTokensIn,
|
|
324
|
+
totalTokensOut,
|
|
325
|
+
totalCacheRead,
|
|
326
|
+
totalCacheWrite,
|
|
327
|
+
totalCost,
|
|
328
|
+
};
|
|
329
|
+
}
|
package/usage/widget.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage monitoring widget — floating glimpseui window showing per-provider
|
|
3
|
+
* free quota status, daily request counts, credit balances, and cumulative
|
|
4
|
+
* token usage across all sessions.
|
|
5
|
+
*
|
|
6
|
+
* Tracks ALL providers dynamically (including local/Ollama models).
|
|
7
|
+
* Launch with /usage command. Toggles on repeated invocation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
11
|
+
import { collectRows, recordSessionRequest } from "../widget/data.ts";
|
|
12
|
+
import { renderWidgetHTML } from "../widget/render.ts";
|
|
13
|
+
import { recordTurn } from "./store.ts";
|
|
14
|
+
|
|
15
|
+
const GLIMPSE_PATH =
|
|
16
|
+
"file:///C:/Users/R3LiC/AppData/Roaming/npm/node_modules/glimpseui/src/glimpse.mjs";
|
|
17
|
+
|
|
18
|
+
let glimpseWin: unknown = null;
|
|
19
|
+
|
|
20
|
+
export async function openUsageWidget(): Promise<void> {
|
|
21
|
+
const { open } = await import(GLIMPSE_PATH);
|
|
22
|
+
glimpseWin = open(renderWidgetHTML(collectRows()), {
|
|
23
|
+
width: 340,
|
|
24
|
+
height: 400,
|
|
25
|
+
title: "Pi Free Usage",
|
|
26
|
+
frameless: true,
|
|
27
|
+
transparent: true,
|
|
28
|
+
floating: true,
|
|
29
|
+
x: 20,
|
|
30
|
+
y: 20,
|
|
31
|
+
});
|
|
32
|
+
(glimpseWin as any).on("closed", () => {
|
|
33
|
+
glimpseWin = null;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function updateWidget(): void {
|
|
38
|
+
if (!glimpseWin) return;
|
|
39
|
+
try {
|
|
40
|
+
(glimpseWin as any).setHTML(renderWidgetHTML(collectRows()));
|
|
41
|
+
} catch {
|
|
42
|
+
glimpseWin = null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function closeWidget(): void {
|
|
47
|
+
if (glimpseWin) {
|
|
48
|
+
(glimpseWin as any).close();
|
|
49
|
+
glimpseWin = null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function registerUsageWidget(pi: ExtensionAPI): void {
|
|
54
|
+
pi.registerCommand("usage", {
|
|
55
|
+
description: "Toggle free model usage dashboard",
|
|
56
|
+
handler: async (_args, ctx) => {
|
|
57
|
+
if (glimpseWin) {
|
|
58
|
+
closeWidget();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
await openUsageWidget();
|
|
63
|
+
} catch {
|
|
64
|
+
ctx.ui.notify(
|
|
65
|
+
"Failed to open usage widget (glimpseui required)",
|
|
66
|
+
"warning",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Track tokens for ANY provider (including local, custom, etc.)
|
|
73
|
+
// and refresh the widget
|
|
74
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
75
|
+
const msg = _event.message;
|
|
76
|
+
const provider = ctx.model?.provider;
|
|
77
|
+
if (msg.role === "assistant" && provider) {
|
|
78
|
+
// Record cumulative usage for this provider
|
|
79
|
+
recordTurn(
|
|
80
|
+
provider,
|
|
81
|
+
msg.usage.input,
|
|
82
|
+
msg.usage.output,
|
|
83
|
+
msg.usage.cost.total,
|
|
84
|
+
);
|
|
85
|
+
// Track session requests for providers not handled by provider-helper
|
|
86
|
+
recordSessionRequest(provider);
|
|
87
|
+
}
|
|
88
|
+
updateWidget();
|
|
89
|
+
});
|
|
90
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
globals: true,
|
|
6
|
+
environment: "node",
|
|
7
|
+
include: ["**/*.test.ts"],
|
|
8
|
+
exclude: ["node_modules", ".pi-lens"],
|
|
9
|
+
coverage: {
|
|
10
|
+
provider: "v8",
|
|
11
|
+
reporter: ["text", "json", "html"],
|
|
12
|
+
exclude: ["node_modules/", ".pi-lens/", "**/*.d.ts", "**/*.test.ts"],
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
resolve: {
|
|
16
|
+
alias: {
|
|
17
|
+
"@": ".",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
});
|
package/widget/data.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget data collection - aggregates metrics from all sources
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
PROVIDER_CLINE,
|
|
7
|
+
PROVIDER_KILO,
|
|
8
|
+
PROVIDER_NVIDIA,
|
|
9
|
+
PROVIDER_OPENROUTER,
|
|
10
|
+
PROVIDER_ZEN,
|
|
11
|
+
} from "../constants.ts";
|
|
12
|
+
import {
|
|
13
|
+
getCachedMetrics,
|
|
14
|
+
getDailyRequestCount,
|
|
15
|
+
getRequestCount,
|
|
16
|
+
} from "../usage/metrics.ts";
|
|
17
|
+
import { getAllCumulativeUsage } from "../usage/store.ts";
|
|
18
|
+
|
|
19
|
+
export interface ProviderRow {
|
|
20
|
+
provider: string;
|
|
21
|
+
key: string;
|
|
22
|
+
icon: string;
|
|
23
|
+
sessionReqs: number;
|
|
24
|
+
dailyReqs: number;
|
|
25
|
+
dailyLimit?: number;
|
|
26
|
+
hourlyLimit?: number;
|
|
27
|
+
remainingToday?: number;
|
|
28
|
+
totalTokensIn: number;
|
|
29
|
+
totalTokensOut: number;
|
|
30
|
+
totalRequests: number;
|
|
31
|
+
costEquivalent: number;
|
|
32
|
+
firstUsed?: string;
|
|
33
|
+
credits?: number;
|
|
34
|
+
creditsLabel?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const KNOWN_PROVIDERS: Record<string, { icon: string; label: string }> = {
|
|
38
|
+
[PROVIDER_KILO]: { icon: "🔥", label: "Kilo" },
|
|
39
|
+
[PROVIDER_OPENROUTER]: { icon: "🔀", label: "OpenRouter" },
|
|
40
|
+
[PROVIDER_ZEN]: { icon: "✦", label: "Zen" },
|
|
41
|
+
[PROVIDER_NVIDIA]: { icon: "⚡", label: "NVIDIA" },
|
|
42
|
+
[PROVIDER_CLINE]: { icon: "🤖", label: "Cline" },
|
|
43
|
+
local: { icon: "💻", label: "Local" },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Session-level request tracking for non-extension providers
|
|
47
|
+
const sessionRequestCounts = new Map<string, number>();
|
|
48
|
+
|
|
49
|
+
export function recordSessionRequest(provider: string): void {
|
|
50
|
+
const current = sessionRequestCounts.get(provider) ?? 0;
|
|
51
|
+
sessionRequestCounts.set(provider, current + 1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function collectRows(): ProviderRow[] {
|
|
55
|
+
const cumulative = getAllCumulativeUsage();
|
|
56
|
+
const orMetrics = getCachedMetrics(PROVIDER_OPENROUTER);
|
|
57
|
+
const kiloMetrics = getCachedMetrics(PROVIDER_KILO);
|
|
58
|
+
|
|
59
|
+
// Discover all providers: known ones + any from cumulative store
|
|
60
|
+
const allKeys = new Set<string>(Object.keys(KNOWN_PROVIDERS));
|
|
61
|
+
for (const key of Object.keys(cumulative)) {
|
|
62
|
+
allKeys.add(key);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const rows: ProviderRow[] = [];
|
|
66
|
+
|
|
67
|
+
for (const key of allKeys) {
|
|
68
|
+
const meta = KNOWN_PROVIDERS[key];
|
|
69
|
+
const c = cumulative[key];
|
|
70
|
+
const sessionReqs =
|
|
71
|
+
getRequestCount(key) || sessionRequestCounts.get(key) || 0;
|
|
72
|
+
|
|
73
|
+
const row: ProviderRow = {
|
|
74
|
+
provider: meta?.label ?? key,
|
|
75
|
+
key,
|
|
76
|
+
icon: meta?.icon ?? "📦",
|
|
77
|
+
sessionReqs,
|
|
78
|
+
dailyReqs: getDailyRequestCount(key) || 0,
|
|
79
|
+
totalTokensIn: c?.tokensIn ?? 0,
|
|
80
|
+
totalTokensOut: c?.tokensOut ?? 0,
|
|
81
|
+
totalRequests: c?.requests ?? 0,
|
|
82
|
+
costEquivalent: c?.costEquivalent ?? 0,
|
|
83
|
+
firstUsed: c?.firstUsed,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Provider-specific known limits
|
|
87
|
+
if (key === PROVIDER_OPENROUTER) {
|
|
88
|
+
row.dailyLimit = orMetrics?.rateLimit?.requestsPerDay;
|
|
89
|
+
row.remainingToday = orMetrics?.rateLimit?.remainingToday;
|
|
90
|
+
row.credits = orMetrics?.credits;
|
|
91
|
+
row.creditsLabel = "credits";
|
|
92
|
+
} else if (key === PROVIDER_KILO) {
|
|
93
|
+
row.hourlyLimit = 200;
|
|
94
|
+
row.credits = kiloMetrics?.balance;
|
|
95
|
+
row.creditsLabel = "balance";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
rows.push(row);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Sort: known providers first (in order), then unknown, then inactive
|
|
102
|
+
const order = Object.keys(KNOWN_PROVIDERS);
|
|
103
|
+
rows.sort((a, b) => {
|
|
104
|
+
const ai = order.indexOf(a.key);
|
|
105
|
+
const bi = order.indexOf(b.key);
|
|
106
|
+
const aOrder = ai >= 0 ? ai : 100;
|
|
107
|
+
const bOrder = bi >= 0 ? bi : 100;
|
|
108
|
+
if (aOrder !== bOrder) return aOrder - bOrder;
|
|
109
|
+
return b.sessionReqs + b.totalRequests - (a.sessionReqs + a.totalRequests);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return rows;
|
|
113
|
+
}
|
package/widget/format.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget formatting utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function formatTokens(n: number): string {
|
|
6
|
+
if (n < 1000) return n.toString();
|
|
7
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
|
|
8
|
+
if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
9
|
+
return `${(n / 1_000_000_000).toFixed(1)}B`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function formatCost(n: number): string {
|
|
13
|
+
if (n < 0.01) return `$${n.toFixed(4)}`;
|
|
14
|
+
if (n < 1) return `$${n.toFixed(3)}`;
|
|
15
|
+
return `$${n.toFixed(2)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function relativeTime(isoDate: string): string {
|
|
19
|
+
const diff = Date.now() - new Date(isoDate).getTime();
|
|
20
|
+
const days = Math.floor(diff / 86_400_000);
|
|
21
|
+
if (days === 0) return "today";
|
|
22
|
+
if (days === 1) return "yesterday";
|
|
23
|
+
if (days < 30) return `${days}d ago`;
|
|
24
|
+
const months = Math.floor(days / 30);
|
|
25
|
+
return `${months}mo ago`;
|
|
26
|
+
}
|
package/widget/render.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget HTML rendering
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ProviderRow } from "./data.ts";
|
|
6
|
+
import { formatCost, formatTokens, relativeTime } from "./format.ts";
|
|
7
|
+
|
|
8
|
+
export function renderWidgetHTML(rows: ProviderRow[]): string {
|
|
9
|
+
let totalTokens = 0,
|
|
10
|
+
totalReqs = 0,
|
|
11
|
+
totalCost = 0;
|
|
12
|
+
for (const r of rows) {
|
|
13
|
+
totalTokens += r.totalTokensIn + r.totalTokensOut;
|
|
14
|
+
totalReqs += r.totalRequests;
|
|
15
|
+
totalCost += r.costEquivalent;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const summaryHTML =
|
|
19
|
+
totalReqs > 0
|
|
20
|
+
? `
|
|
21
|
+
<div style="background: rgba(255,255,255,0.04); border-radius: 8px; padding: 10px 12px; margin-bottom: 12px;">
|
|
22
|
+
<div style="display: flex; justify-content: space-between; font-size: 13px; font-weight: 500;">
|
|
23
|
+
<span>Total free value</span>
|
|
24
|
+
<span style="color: #48bb78;">${formatCost(totalCost)} saved</span>
|
|
25
|
+
</div>
|
|
26
|
+
<div style="font-size: 11px; color: #888; margin-top: 3px;">
|
|
27
|
+
${formatTokens(totalTokens)} tokens · ${totalReqs} requests
|
|
28
|
+
</div>
|
|
29
|
+
</div>`
|
|
30
|
+
: "";
|
|
31
|
+
|
|
32
|
+
const providerRows = rows.map((r) => renderProviderRow(r)).join("\n");
|
|
33
|
+
|
|
34
|
+
return `<!DOCTYPE html>
|
|
35
|
+
<html><head><meta charset="UTF-8">
|
|
36
|
+
<style>
|
|
37
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
38
|
+
body {
|
|
39
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
40
|
+
background: rgba(24, 24, 30, 0.95);
|
|
41
|
+
color: #e0e0e0; padding: 14px 16px;
|
|
42
|
+
min-height: 100vh; backdrop-filter: blur(20px);
|
|
43
|
+
}
|
|
44
|
+
.header { font-size: 12px; color: #666; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; }
|
|
45
|
+
</style></head>
|
|
46
|
+
<body>
|
|
47
|
+
<div class="header">Free Usage</div>
|
|
48
|
+
${summaryHTML}
|
|
49
|
+
${providerRows}
|
|
50
|
+
<div style="font-size: 10px; color: #555; margin-top: 10px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.05);">
|
|
51
|
+
Each gateway has independent quotas — using multiple providers multiplies capacity.
|
|
52
|
+
</div>
|
|
53
|
+
</body></html>`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function renderProviderRow(r: ProviderRow): string {
|
|
57
|
+
const hasActivity = r.sessionReqs > 0 || r.totalRequests > 0;
|
|
58
|
+
const quotaBar = renderQuotaBar(r);
|
|
59
|
+
const infoHTML = renderInfoLine(r);
|
|
60
|
+
|
|
61
|
+
return `
|
|
62
|
+
<div style="padding: 7px 0; ${hasActivity ? "" : "opacity: 0.3;"}">
|
|
63
|
+
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
64
|
+
<span style="font-weight: 500; font-size: 13px;">${r.icon} ${r.provider}</span>
|
|
65
|
+
<span style="font-size: 10px; color: #888;">${r.sessionReqs} this session</span>
|
|
66
|
+
</div>
|
|
67
|
+
${infoHTML}
|
|
68
|
+
${quotaBar}
|
|
69
|
+
</div>`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function renderQuotaBar(r: ProviderRow): string {
|
|
73
|
+
if (!r.dailyLimit || r.dailyLimit <= 0) return "";
|
|
74
|
+
|
|
75
|
+
const pct = Math.min(100, Math.round((r.dailyReqs / r.dailyLimit) * 100));
|
|
76
|
+
const color = pct > 80 ? "#e53e3e" : pct > 50 ? "#ecc94b" : "#48bb78";
|
|
77
|
+
|
|
78
|
+
return `
|
|
79
|
+
<div style="display: flex; align-items: center; gap: 8px; margin-top: 3px;">
|
|
80
|
+
<div style="flex: 1; height: 4px; background: rgba(255,255,255,0.08); border-radius: 2px; overflow: hidden;">
|
|
81
|
+
<div style="width: ${pct}%; height: 100%; background: ${color}; border-radius: 2px;"></div>
|
|
82
|
+
</div>
|
|
83
|
+
<span style="font-size: 10px; color: #666;">${r.dailyReqs}/${r.dailyLimit}</span>
|
|
84
|
+
</div>`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function renderInfoLine(r: ProviderRow): string {
|
|
88
|
+
const infoParts: string[] = [];
|
|
89
|
+
|
|
90
|
+
if (r.totalRequests > 0) {
|
|
91
|
+
infoParts.push(
|
|
92
|
+
`${formatTokens(r.totalTokensIn + r.totalTokensOut)} tok · ${r.totalRequests} reqs`,
|
|
93
|
+
);
|
|
94
|
+
if (r.costEquivalent > 0) {
|
|
95
|
+
infoParts.push(`≈${formatCost(r.costEquivalent)}`);
|
|
96
|
+
}
|
|
97
|
+
if (r.firstUsed) {
|
|
98
|
+
infoParts.push(`since ${relativeTime(r.firstUsed)}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (r.remainingToday !== undefined) {
|
|
102
|
+
infoParts.push(`${r.remainingToday} left today`);
|
|
103
|
+
}
|
|
104
|
+
if (r.hourlyLimit) {
|
|
105
|
+
infoParts.push(`${r.hourlyLimit}/hr limit`);
|
|
106
|
+
}
|
|
107
|
+
if (r.credits !== undefined) {
|
|
108
|
+
infoParts.push(`💰 ${formatCost(r.credits)}`);
|
|
109
|
+
}
|
|
110
|
+
if (r.key === "local" && r.totalRequests > 0) {
|
|
111
|
+
infoParts.push("always free");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (infoParts.length === 0) return "";
|
|
115
|
+
|
|
116
|
+
return `<div style="font-size: 10px; color: #777; margin-top: 2px;">${infoParts.join(" · ")}</div>`;
|
|
117
|
+
}
|