@iammralmost/usage-hud 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +45 -2
- package/client.js +863 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +11 -0
- package/index.js +498 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +43 -4
- package/prices.json +17 -0
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="Usage HUD">
|
|
2
|
+
<rect x="1" y="1" width="46" height="46" rx="12" fill="#0f1720"/>
|
|
3
|
+
<rect x="1" y="1" width="46" height="46" rx="12" fill="none" stroke="#2f3f4f" stroke-width="2"/>
|
|
4
|
+
<g fill="none" stroke="#6fb7ff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
|
5
|
+
<ellipse cx="24" cy="15" rx="10" ry="4"/>
|
|
6
|
+
<path d="M14 15v8c0 2.2 4.5 4 10 4s10-1.8 10-4v-8"/>
|
|
7
|
+
<path d="M14 23v8c0 2.2 4.5 4 10 4s10-1.8 10-4v-8"/>
|
|
8
|
+
</g>
|
|
9
|
+
<circle cx="34" cy="34" r="5" fill="#ffc24d"/>
|
|
10
|
+
<path d="M34 31.5v5M31.5 34h5" stroke="#0f1720" stroke-width="1.8" stroke-linecap="round"/>
|
|
11
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of the usage-hud bundle.
|
|
3
|
+
*
|
|
4
|
+
* The readout itself is rendered by the Client module through
|
|
5
|
+
* `conversation.composer.dock`. This half answers `GET /usage-hud/today`:
|
|
6
|
+
* today's provider-reported usage aggregated from the durable session logs,
|
|
7
|
+
* bucketed by local hour and by the provider's peak/off-peak price window. DSH
|
|
8
|
+
* stores no currency prices, so the fee is computed from `prices.json` beside
|
|
9
|
+
* this file (with a shipped fallback).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { zstdDecompressSync } from 'node:zlib';
|
|
17
|
+
|
|
18
|
+
const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const PRICES = join(PLUGIN_DIR, 'prices.json');
|
|
20
|
+
|
|
21
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
22
|
+
const MAX_LOG_BYTES = 256 * 1024 * 1024;
|
|
23
|
+
const CACHE_TTL_MS = 90000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Shipped price table; `prices.json` beside this file overrides it.
|
|
27
|
+
*
|
|
28
|
+
* Prices are the provider's PEAK rate in CNY per million tokens. Off-peak is the
|
|
29
|
+
* peak rate times `offPeakDiscount`. The peak window is DeepSeek's: Beijing time,
|
|
30
|
+
* Monday to Friday excluding Chinese statutory holidays, 09:00-12:00 and
|
|
31
|
+
* 14:00-18:00; everything else is off-peak.
|
|
32
|
+
*/
|
|
33
|
+
const DEFAULT_PRICES = {
|
|
34
|
+
currency: 'CNY',
|
|
35
|
+
unit: 'per-million-tokens',
|
|
36
|
+
fallbackModel: 'deepseek-flash',
|
|
37
|
+
pricingBasis: 'peak',
|
|
38
|
+
offPeakDiscount: 0.5,
|
|
39
|
+
peak: { weekdays: [1, 2, 3, 4, 5], intervals: [[540, 720], [840, 1080]], holidays: [] },
|
|
40
|
+
models: {
|
|
41
|
+
'deepseek-flash': { cacheHit: 0.04, cacheMiss: 2, output: 8 },
|
|
42
|
+
'deepseek-v4-pro': { cacheHit: 0.3, cacheMiss: 9, output: 27 },
|
|
43
|
+
},
|
|
44
|
+
note: 'Peak prices in CNY per million tokens; off-peak scales by offPeakDiscount.',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** @returns the price table, preferring `prices.json`. */
|
|
48
|
+
function loadPrices() {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(readFileSync(PRICES, 'utf8'));
|
|
51
|
+
return { ...DEFAULT_PRICES, ...parsed, models: { ...DEFAULT_PRICES.models, ...(parsed.models ?? {}) } };
|
|
52
|
+
} catch {
|
|
53
|
+
return DEFAULT_PRICES;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @returns local midnight of the given instant. */
|
|
58
|
+
function localMidnight(instant) {
|
|
59
|
+
const date = new Date(instant);
|
|
60
|
+
date.setHours(0, 0, 0, 0);
|
|
61
|
+
return date.getTime();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @returns the minute of the local day, 0..1439. */
|
|
65
|
+
function minuteOfDay(instant) {
|
|
66
|
+
const date = new Date(instant);
|
|
67
|
+
return date.getHours() * 60 + date.getMinutes();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Decode a jsonl.zstd session log, whose frames are appended independently.
|
|
72
|
+
* @param buffer - raw file bytes.
|
|
73
|
+
* @returns parsed events, in file order.
|
|
74
|
+
*/
|
|
75
|
+
function decodeEvents(buffer) {
|
|
76
|
+
const magics = [];
|
|
77
|
+
for (let index = 0; index + 3 < buffer.length; index += 1) {
|
|
78
|
+
if (buffer[index] === 0x28 && buffer[index + 1] === 0xb5 && buffer[index + 2] === 0x2f && buffer[index + 3] === 0xfd) {
|
|
79
|
+
magics.push(index);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
let text = '';
|
|
83
|
+
for (let index = 0; index < magics.length; index += 1) {
|
|
84
|
+
const start = magics[index];
|
|
85
|
+
const end = index + 1 < magics.length ? magics[index + 1] : buffer.length;
|
|
86
|
+
try {
|
|
87
|
+
text += zstdDecompressSync(buffer.subarray(start, end)).toString('utf8');
|
|
88
|
+
} catch {
|
|
89
|
+
// A byte pattern that only looks like a frame boundary; skip it.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const events = [];
|
|
93
|
+
for (const line of text.split('\n')) {
|
|
94
|
+
if (line === '') continue;
|
|
95
|
+
try {
|
|
96
|
+
events.push(JSON.parse(line));
|
|
97
|
+
} catch {
|
|
98
|
+
// A line cut by a frame boundary; the next frame repeats nothing.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return events;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** @returns the account/session log files that can hold events from `since`. */
|
|
105
|
+
function recentLogs(root, since) {
|
|
106
|
+
const files = [];
|
|
107
|
+
let workspaces;
|
|
108
|
+
try {
|
|
109
|
+
workspaces = readdirSync(root, { withFileTypes: true });
|
|
110
|
+
} catch {
|
|
111
|
+
return files;
|
|
112
|
+
}
|
|
113
|
+
for (const workspace of workspaces) {
|
|
114
|
+
if (!workspace.isDirectory()) continue;
|
|
115
|
+
const workspaceDir = join(root, workspace.name);
|
|
116
|
+
let sessions;
|
|
117
|
+
try {
|
|
118
|
+
sessions = readdirSync(workspaceDir, { withFileTypes: true });
|
|
119
|
+
} catch {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
for (const session of sessions) {
|
|
123
|
+
if (!session.isDirectory()) continue;
|
|
124
|
+
const sessionDir = join(workspaceDir, session.name);
|
|
125
|
+
let entries;
|
|
126
|
+
try {
|
|
127
|
+
entries = readdirSync(sessionDir, { withFileTypes: true });
|
|
128
|
+
} catch {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl.zstd')) continue;
|
|
133
|
+
const path = join(sessionDir, entry.name);
|
|
134
|
+
try {
|
|
135
|
+
const stat = statSync(path);
|
|
136
|
+
if (stat.size > MAX_LOG_BYTES) continue;
|
|
137
|
+
if (stat.mtimeMs < since) continue;
|
|
138
|
+
files.push({ path, mtimeMs: stat.mtimeMs });
|
|
139
|
+
} catch {
|
|
140
|
+
// Unreadable file: leave it out.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return files;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** @returns a fresh accumulator for one bucket. */
|
|
149
|
+
function emptyBucket() {
|
|
150
|
+
return { requests: 0, input: 0, cacheRead: 0, cacheWrite: 0, output: 0, tokens: 0, fee: 0 };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** @param target - bucket to add into. @param usage - provider-reported usage. */
|
|
154
|
+
function addUsage(target, usage) {
|
|
155
|
+
const input = Number(usage.inputTokens) || 0;
|
|
156
|
+
const cacheRead = Number(usage.cacheReadTokens) || 0;
|
|
157
|
+
const cacheWrite = Number(usage.cacheWriteTokens) || 0;
|
|
158
|
+
const output = Number(usage.outputTokens) || 0;
|
|
159
|
+
target.requests += 1;
|
|
160
|
+
target.input += input;
|
|
161
|
+
target.cacheRead += cacheRead;
|
|
162
|
+
target.cacheWrite += cacheWrite;
|
|
163
|
+
target.output += output;
|
|
164
|
+
target.tokens += input + cacheRead + cacheWrite + output;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param instant - epoch ms.
|
|
169
|
+
* @param peak - the peak-window definition.
|
|
170
|
+
* @returns whether the instant falls in the provider's peak price window.
|
|
171
|
+
*/
|
|
172
|
+
function isPeakTime(instant, peak) {
|
|
173
|
+
const date = new Date(instant);
|
|
174
|
+
const weekdays = Array.isArray(peak.weekdays) ? peak.weekdays : [1, 2, 3, 4, 5];
|
|
175
|
+
if (!weekdays.includes(date.getDay())) return false;
|
|
176
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
177
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
178
|
+
const key = `${date.getFullYear()}-${month}-${day}`;
|
|
179
|
+
if (Array.isArray(peak.holidays) && peak.holidays.includes(key)) return false;
|
|
180
|
+
const minute = date.getHours() * 60 + date.getMinutes();
|
|
181
|
+
const intervals = Array.isArray(peak.intervals) ? peak.intervals : [];
|
|
182
|
+
return intervals.some(([from, to]) => minute >= from && minute < to);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param usage - provider-reported usage for one request.
|
|
187
|
+
* @param price - peak-rate prices for one model.
|
|
188
|
+
* @param discount - window multiplier.
|
|
189
|
+
* @returns fee for that single request.
|
|
190
|
+
*/
|
|
191
|
+
function feeOfUsage(usage, price, discount) {
|
|
192
|
+
const uncached = ((usage.input + usage.cacheWrite) / 1e6) * price.cacheMiss;
|
|
193
|
+
const cached = (usage.cacheRead / 1e6) * price.cacheHit;
|
|
194
|
+
const output = (usage.output / 1e6) * price.output;
|
|
195
|
+
return (uncached + cached + output) * discount;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** @returns the sessions root under the harness home. */
|
|
199
|
+
function sessionsRoot() {
|
|
200
|
+
return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'sessions');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** @returns the log file of one session, or undefined when it is not on disk. */
|
|
204
|
+
function sessionLog(sessionId) {
|
|
205
|
+
let workspaces;
|
|
206
|
+
try {
|
|
207
|
+
workspaces = readdirSync(sessionsRoot(), { withFileTypes: true });
|
|
208
|
+
} catch {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
for (const workspace of workspaces) {
|
|
212
|
+
if (!workspace.isDirectory()) continue;
|
|
213
|
+
const sessionDir = join(sessionsRoot(), workspace.name, sessionId);
|
|
214
|
+
try {
|
|
215
|
+
const entries = readdirSync(sessionDir, { withFileTypes: true });
|
|
216
|
+
for (const entry of entries) {
|
|
217
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl.zstd')) continue;
|
|
218
|
+
return join(sessionDir, entry.name);
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
// Not this workspace.
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Price every assistant message of one session, so the message action strip can
|
|
229
|
+
* show what that reply cost.
|
|
230
|
+
* @param sessionId - durable session id.
|
|
231
|
+
* @returns per-message fee and token totals, keyed by message id.
|
|
232
|
+
*/
|
|
233
|
+
function aggregateSession(sessionId) {
|
|
234
|
+
const file = sessionLog(sessionId);
|
|
235
|
+
if (file === undefined) return { session: sessionId, found: false, currency: loadPrices().currency, messages: {} };
|
|
236
|
+
|
|
237
|
+
const prices = loadPrices();
|
|
238
|
+
const discount = typeof prices.offPeakDiscount === 'number' ? prices.offPeakDiscount : 0.5;
|
|
239
|
+
const peakWindow = prices.peak ?? DEFAULT_PRICES.peak;
|
|
240
|
+
|
|
241
|
+
let events;
|
|
242
|
+
try {
|
|
243
|
+
events = decodeEvents(readFileSync(file));
|
|
244
|
+
} catch {
|
|
245
|
+
return { session: sessionId, found: false, currency: prices.currency, messages: {} };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const messages = {};
|
|
249
|
+
const turns = new Map();
|
|
250
|
+
let route;
|
|
251
|
+
for (const event of events) {
|
|
252
|
+
if (event?.type === 'request/context' && typeof event.data?.model === 'string') {
|
|
253
|
+
route = event.data.model;
|
|
254
|
+
} else if (event?.type === 'request/header' && typeof event.data?.header?.config?.model === 'string') {
|
|
255
|
+
route = event.data.header.config.model;
|
|
256
|
+
}
|
|
257
|
+
if (event?.type !== 'assistant/message' || event.data?.usage === undefined) continue;
|
|
258
|
+
const id = event.data.message?.id;
|
|
259
|
+
const time = event.time;
|
|
260
|
+
if (typeof id !== 'string' || typeof time !== 'number') continue;
|
|
261
|
+
|
|
262
|
+
const usage = event.data.usage;
|
|
263
|
+
const model = route ?? 'unknown';
|
|
264
|
+
const exact = prices.models[model] !== undefined;
|
|
265
|
+
const price = exact ? prices.models[model] : prices.models[prices.fallbackModel];
|
|
266
|
+
const peak = isPeakTime(time, peakWindow);
|
|
267
|
+
const amount = feeOfUsage(
|
|
268
|
+
{
|
|
269
|
+
input: Number(usage.inputTokens) || 0,
|
|
270
|
+
cacheRead: Number(usage.cacheReadTokens) || 0,
|
|
271
|
+
cacheWrite: Number(usage.cacheWriteTokens) || 0,
|
|
272
|
+
output: Number(usage.outputTokens) || 0,
|
|
273
|
+
},
|
|
274
|
+
price,
|
|
275
|
+
peak ? 1 : discount,
|
|
276
|
+
);
|
|
277
|
+
messages[id] = {
|
|
278
|
+
model,
|
|
279
|
+
peak,
|
|
280
|
+
turn: event.data.turn,
|
|
281
|
+
fee: amount,
|
|
282
|
+
input: Number(usage.inputTokens) || 0,
|
|
283
|
+
cacheRead: Number(usage.cacheReadTokens) || 0,
|
|
284
|
+
cacheWrite: Number(usage.cacheWriteTokens) || 0,
|
|
285
|
+
output: Number(usage.outputTokens) || 0,
|
|
286
|
+
tokens:
|
|
287
|
+
(Number(usage.inputTokens) || 0) +
|
|
288
|
+
(Number(usage.cacheReadTokens) || 0) +
|
|
289
|
+
(Number(usage.cacheWriteTokens) || 0) +
|
|
290
|
+
(Number(usage.outputTokens) || 0),
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// The message action strip shows the whole Turn's token total, so the fee
|
|
294
|
+
// beside it must be the Turn's fee — one message's share is an order of
|
|
295
|
+
// magnitude smaller and would read as a mismatch.
|
|
296
|
+
const turn = event.data.turn;
|
|
297
|
+
if (typeof turn === 'number') {
|
|
298
|
+
const aggregate = turns.get(turn) ?? { tokens: 0, fee: 0, messages: 0 };
|
|
299
|
+
aggregate.tokens += messages[id].tokens;
|
|
300
|
+
aggregate.fee += amount;
|
|
301
|
+
aggregate.messages += 1;
|
|
302
|
+
turns.set(turn, aggregate);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
for (const entry of Object.values(messages)) {
|
|
307
|
+
const aggregate = typeof entry.turn === 'number' ? turns.get(entry.turn) : undefined;
|
|
308
|
+
if (aggregate === undefined) continue;
|
|
309
|
+
entry.turnTokens = aggregate.tokens;
|
|
310
|
+
entry.turnFee = aggregate.fee;
|
|
311
|
+
entry.turnMessages = aggregate.messages;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return { session: sessionId, found: true, currency: prices.currency, messages };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** @returns the whole day's aggregate. */
|
|
318
|
+
function aggregateToday(now) {
|
|
319
|
+
const prices = loadPrices();
|
|
320
|
+
const discount = typeof prices.offPeakDiscount === 'number' ? prices.offPeakDiscount : 0.5;
|
|
321
|
+
const peakWindow = prices.peak ?? DEFAULT_PRICES.peak;
|
|
322
|
+
const start = localMidnight(now);
|
|
323
|
+
const end = start + DAY_MS;
|
|
324
|
+
const roots = [process.env.DSH_HOME ?? join(homedir(), '.dsh')].map((home) => join(home, 'sessions'));
|
|
325
|
+
|
|
326
|
+
const hours = Array.from({ length: 24 }, () => emptyBucket());
|
|
327
|
+
const models = new Map();
|
|
328
|
+
let scanned = 0;
|
|
329
|
+
|
|
330
|
+
/** @param instant - event time. @returns the model price row and its window multiplier. */
|
|
331
|
+
const priceAt = (instant, model) => {
|
|
332
|
+
const exact = prices.models[model] !== undefined;
|
|
333
|
+
const price = exact ? prices.models[model] : prices.models[prices.fallbackModel];
|
|
334
|
+
return { price, exact, multiplier: isPeakTime(instant, peakWindow) ? 1 : discount };
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
for (const root of roots) {
|
|
338
|
+
for (const file of recentLogs(root, start - 2 * 60 * 60 * 1000)) {
|
|
339
|
+
let events;
|
|
340
|
+
try {
|
|
341
|
+
events = decodeEvents(readFileSync(file.path));
|
|
342
|
+
} catch {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
scanned += 1;
|
|
346
|
+
let route;
|
|
347
|
+
for (const event of events) {
|
|
348
|
+
if (event?.type === 'request/context' && typeof event.data?.model === 'string') {
|
|
349
|
+
route = event.data.model;
|
|
350
|
+
} else if (event?.type === 'request/header' && typeof event.data?.header?.config?.model === 'string') {
|
|
351
|
+
route = event.data.header.config.model;
|
|
352
|
+
}
|
|
353
|
+
const usage = event?.type === 'assistant/message' ? event.data?.usage : undefined;
|
|
354
|
+
const time = event?.time;
|
|
355
|
+
if (usage === undefined || typeof time !== 'number') continue;
|
|
356
|
+
if (time < start || time >= end) continue;
|
|
357
|
+
|
|
358
|
+
const model = route ?? 'unknown';
|
|
359
|
+
const { price, multiplier } = priceAt(time, model);
|
|
360
|
+
const sample = {
|
|
361
|
+
input: Number(usage.inputTokens) || 0,
|
|
362
|
+
cacheRead: Number(usage.cacheReadTokens) || 0,
|
|
363
|
+
cacheWrite: Number(usage.cacheWriteTokens) || 0,
|
|
364
|
+
output: Number(usage.outputTokens) || 0,
|
|
365
|
+
};
|
|
366
|
+
const amount = feeOfUsage(sample, price, multiplier);
|
|
367
|
+
|
|
368
|
+
const hour = new Date(time).getHours();
|
|
369
|
+
addUsage(hours[hour], usage);
|
|
370
|
+
hours[hour].fee += amount;
|
|
371
|
+
if (!models.has(model)) models.set(model, emptyBucket());
|
|
372
|
+
addUsage(models.get(model), usage);
|
|
373
|
+
models.get(model).fee += amount;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const totals = emptyBucket();
|
|
379
|
+
const peak = emptyBucket();
|
|
380
|
+
const offPeak = emptyBucket();
|
|
381
|
+
const hoursOut = [];
|
|
382
|
+
|
|
383
|
+
for (let hour = 0; hour < 24; hour += 1) {
|
|
384
|
+
const bucket = hours[hour];
|
|
385
|
+
for (const key of ['requests', 'input', 'cacheRead', 'cacheWrite', 'output', 'tokens', 'fee']) totals[key] += bucket[key];
|
|
386
|
+
// A single local date means the weekday is constant, so the window of an
|
|
387
|
+
// hour is decided by its start minute.
|
|
388
|
+
const hourStart = start + hour * 60 * 60 * 1000;
|
|
389
|
+
const window = isPeakTime(hourStart, peakWindow) ? 'peak' : 'offPeak';
|
|
390
|
+
const target = window === 'peak' ? peak : offPeak;
|
|
391
|
+
for (const key of ['requests', 'input', 'cacheRead', 'cacheWrite', 'output', 'tokens', 'fee']) target[key] += bucket[key];
|
|
392
|
+
hoursOut.push({ hour, ...bucket, window });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const modelsOut = [];
|
|
396
|
+
for (const [model, bucket] of models) {
|
|
397
|
+
modelsOut.push({ model, ...bucket, pricing: prices.models[model] === undefined ? 'fallback' : 'exact' });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
day: new Date(start).toISOString().slice(0, 10),
|
|
402
|
+
start,
|
|
403
|
+
end,
|
|
404
|
+
now,
|
|
405
|
+
currency: prices.currency,
|
|
406
|
+
unit: prices.unit,
|
|
407
|
+
pricingBasis: prices.pricingBasis ?? 'peak',
|
|
408
|
+
offPeakDiscount: discount,
|
|
409
|
+
peakWindow: {
|
|
410
|
+
weekdays: peakWindow.weekdays ?? [1, 2, 3, 4, 5],
|
|
411
|
+
intervals: peakWindow.intervals ?? DEFAULT_PRICES.peak.intervals,
|
|
412
|
+
holidays: peakWindow.holidays ?? [],
|
|
413
|
+
},
|
|
414
|
+
scannedFiles: scanned,
|
|
415
|
+
totals,
|
|
416
|
+
peak,
|
|
417
|
+
offPeak,
|
|
418
|
+
hours: hoursOut,
|
|
419
|
+
models: modelsOut.sort((left, right) => right.tokens - left.tokens),
|
|
420
|
+
pricing: {
|
|
421
|
+
fallbackModel: prices.fallbackModel,
|
|
422
|
+
known: Object.keys(prices.models),
|
|
423
|
+
exact: modelsOut.every((entry) => entry.pricing === 'exact'),
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
const SESSION_ID = /^[A-Za-z0-9._-]+$/;
|
|
428
|
+
|
|
429
|
+
export function apply(ctx) {
|
|
430
|
+
let todayCache;
|
|
431
|
+
const sessionCache = new Map();
|
|
432
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
433
|
+
webCtx.effect(
|
|
434
|
+
() =>
|
|
435
|
+
webCtx.webServer.register({
|
|
436
|
+
kind: 'prefix',
|
|
437
|
+
path: '/usage-hud',
|
|
438
|
+
handler: (req, res) => {
|
|
439
|
+
const url = req.url ?? '/usage-hud';
|
|
440
|
+
const send = (payload) => {
|
|
441
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
442
|
+
res.end(JSON.stringify(payload));
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
if (req.method !== 'GET') {
|
|
446
|
+
res.writeHead(404);
|
|
447
|
+
res.end();
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const now = Date.now();
|
|
452
|
+
const force = url.includes('force=1');
|
|
453
|
+
|
|
454
|
+
if (url.startsWith('/usage-hud/today')) {
|
|
455
|
+
if (force || todayCache === undefined || now - todayCache.at > CACHE_TTL_MS) {
|
|
456
|
+
let payload;
|
|
457
|
+
try {
|
|
458
|
+
payload = aggregateToday(now);
|
|
459
|
+
} catch (error) {
|
|
460
|
+
payload = { error: String(error && (error.message || error)) };
|
|
461
|
+
}
|
|
462
|
+
todayCache = { at: now, payload };
|
|
463
|
+
}
|
|
464
|
+
send(todayCache.payload);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (url.startsWith('/usage-hud/session')) {
|
|
469
|
+
const sessionId = new URL(url, 'http://x').searchParams.get('session') ?? '';
|
|
470
|
+
if (!SESSION_ID.test(sessionId)) {
|
|
471
|
+
res.writeHead(400);
|
|
472
|
+
res.end();
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const cached = sessionCache.get(sessionId);
|
|
476
|
+
if (!force && cached !== undefined && now - cached.at <= CACHE_TTL_MS) {
|
|
477
|
+
send(cached.payload);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
let payload;
|
|
481
|
+
try {
|
|
482
|
+
payload = aggregateSession(sessionId);
|
|
483
|
+
} catch (error) {
|
|
484
|
+
payload = { session: sessionId, found: false, messages: {}, error: String(error && (error.message || error)) };
|
|
485
|
+
}
|
|
486
|
+
sessionCache.set(sessionId, { at: now, payload });
|
|
487
|
+
send(payload);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
res.writeHead(404);
|
|
492
|
+
res.end();
|
|
493
|
+
},
|
|
494
|
+
}),
|
|
495
|
+
'usage-hud: usage routes',
|
|
496
|
+
);
|
|
497
|
+
});
|
|
498
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iammralmost/usage-hud",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Always-visible token, cache-hit, context and balance readout in the composer dock.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "iammralmost",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/iammralmost/horizon.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/iammralmost/horizon#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/iammralmost/horizon/issues"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./index.js",
|
|
18
|
+
"./client": "./client.js",
|
|
19
|
+
"./package.json": "./package.json",
|
|
20
|
+
"./locale/*.json": "./locale/*.json",
|
|
21
|
+
"./icon": "./icon.svg"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"index.js",
|
|
25
|
+
"client.js",
|
|
26
|
+
"prices.json",
|
|
27
|
+
"cordis.patch.yml",
|
|
28
|
+
"locale/*.json",
|
|
29
|
+
"icon.svg",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"dsh": {
|
|
34
|
+
"bundle": {
|
|
35
|
+
"patch": "./cordis.patch.yml"
|
|
36
|
+
},
|
|
37
|
+
"client": {
|
|
38
|
+
"platform": "web",
|
|
39
|
+
"immediately": true,
|
|
40
|
+
"inject": [
|
|
41
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
package/prices.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"currency": "CNY",
|
|
3
|
+
"unit": "per-million-tokens",
|
|
4
|
+
"fallbackModel": "deepseek-flash",
|
|
5
|
+
"pricingBasis": "peak",
|
|
6
|
+
"offPeakDiscount": 0.5,
|
|
7
|
+
"peak": {
|
|
8
|
+
"weekdays": [1, 2, 3, 4, 5],
|
|
9
|
+
"intervals": [[540, 720], [840, 1080]],
|
|
10
|
+
"holidays": []
|
|
11
|
+
},
|
|
12
|
+
"models": {
|
|
13
|
+
"deepseek-flash": { "cacheHit": 0.04, "cacheMiss": 2, "output": 8 },
|
|
14
|
+
"deepseek-v4-pro": { "cacheHit": 0.3, "cacheMiss": 9, "output": 27 }
|
|
15
|
+
},
|
|
16
|
+
"note": "Prices are CNY per million tokens at the PEAK rate; off-peak multiplies by offPeakDiscount. Peak is Beijing time, Monday-Friday excluding Chinese statutory holidays, 09:00-12:00 and 14:00-18:00 (minutes of the local day). Add statutory holidays to peak.holidays as YYYY-MM-DD; while a holiday is missing, its weekday is priced as peak."
|
|
17
|
+
}
|