dsh-token-total 1.0.1 → 1.0.2
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 +2 -1
- package/lib/client.js +303 -22
- package/lib/index.js +196 -11
- package/lib/types/client/ContextUsageEntry.d.ts +22 -0
- package/lib/types/client/context-meter-core.d.ts +26 -0
- package/lib/types/client/context-takeover.d.ts +4 -0
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/session-usage-core.d.ts +1 -2
- package/lib/types/client/use-session-usage.d.ts +1 -1
- package/lib/types/collector.d.ts +19 -3
- package/lib/types/query.d.ts +8 -0
- package/lib/types/store.d.ts +32 -0
- package/lib/types/wire.d.ts +7 -0
- package/package.json +1 -1
- package/src/client/ContextUsageEntry.module.css +20 -0
- package/src/client/ContextUsageEntry.tsx +89 -0
- package/src/client/StatsLineEnhanced.tsx +2 -2
- package/src/client/TokenTotalPanel.tsx +5 -3
- package/src/client/context-meter-core.ts +54 -0
- package/src/client/context-takeover.ts +35 -0
- package/src/client/index.tsx +21 -3
- package/src/client/locales.ts +4 -0
- package/src/client/session-usage-core.ts +7 -11
- package/src/client/use-session-usage.ts +1 -1
- package/src/collector.ts +81 -16
- package/src/index.ts +29 -0
- package/src/query.ts +23 -8
- package/src/routes.ts +6 -0
- package/src/store.ts +136 -0
- package/src/wire.ts +7 -0
package/lib/index.js
CHANGED
|
@@ -89,6 +89,7 @@ function aggregateSamples(samples, filter, granularity = "day") {
|
|
|
89
89
|
outputTokens: 0,
|
|
90
90
|
cacheTokens: 0,
|
|
91
91
|
requests: 0,
|
|
92
|
+
failures: 0,
|
|
92
93
|
turns: 0,
|
|
93
94
|
cacheHit: 0,
|
|
94
95
|
cacheMiss: 0,
|
|
@@ -111,6 +112,7 @@ function aggregateSamples(samples, filter, granularity = "day") {
|
|
|
111
112
|
const bucketByProvider = /* @__PURE__ */ new Map();
|
|
112
113
|
const bucketTotals = /* @__PURE__ */ new Map();
|
|
113
114
|
const bucketRequests = /* @__PURE__ */ new Map();
|
|
115
|
+
const bucketFailures = /* @__PURE__ */ new Map();
|
|
114
116
|
const bucketTurns = /* @__PURE__ */ new Map();
|
|
115
117
|
const bucketCacheHit = /* @__PURE__ */ new Map();
|
|
116
118
|
const bucketCacheMiss = /* @__PURE__ */ new Map();
|
|
@@ -123,11 +125,16 @@ function aggregateSamples(samples, filter, granularity = "day") {
|
|
|
123
125
|
continue;
|
|
124
126
|
}
|
|
125
127
|
const total = sample.inputTokens + sample.outputTokens + sample.cacheReadTokens + sample.cacheWriteTokens;
|
|
128
|
+
const hasTokenDelta = sample.inputTokens !== 0 || sample.outputTokens !== 0 || sample.cacheReadTokens !== 0 || sample.cacheWriteTokens !== 0;
|
|
129
|
+
if (sample.failed) {
|
|
130
|
+
out.failures++;
|
|
131
|
+
bucketFailures.set(bucket, (bucketFailures.get(bucket) ?? 0) + 1);
|
|
132
|
+
}
|
|
126
133
|
if (sample.request) {
|
|
127
134
|
out.requests++;
|
|
128
135
|
bucketRequests.set(bucket, (bucketRequests.get(bucket) ?? 0) + 1);
|
|
129
|
-
if (total <= 0) continue;
|
|
130
136
|
}
|
|
137
|
+
if (!hasTokenDelta) continue;
|
|
131
138
|
out.tokens += total;
|
|
132
139
|
out.inputTokens += sample.inputTokens;
|
|
133
140
|
out.outputTokens += sample.outputTokens;
|
|
@@ -196,6 +203,7 @@ function aggregateSamples(samples, filter, granularity = "day") {
|
|
|
196
203
|
byModel: byModelObj,
|
|
197
204
|
byProvider: byProviderObj,
|
|
198
205
|
requests: bucketRequests.get(bucket) ?? 0,
|
|
206
|
+
failures: bucketFailures.get(bucket) ?? 0,
|
|
199
207
|
turns: bucketTurns.get(bucket) ?? 0,
|
|
200
208
|
cacheHit: bucketCacheHit.get(bucket) ?? 0,
|
|
201
209
|
cacheMiss: bucketCacheMiss.get(bucket) ?? 0
|
|
@@ -293,6 +301,7 @@ const usageDayRowSchema = z.object({
|
|
|
293
301
|
cacheReadTokens: z.number(),
|
|
294
302
|
cacheWriteTokens: z.number(),
|
|
295
303
|
requests: z.number(),
|
|
304
|
+
failures: z.number().default(0),
|
|
296
305
|
turns: z.number(),
|
|
297
306
|
lastSeen: z.number()
|
|
298
307
|
});
|
|
@@ -305,6 +314,7 @@ const usageHourRowSchema = z.object({
|
|
|
305
314
|
cacheReadTokens: z.number(),
|
|
306
315
|
cacheWriteTokens: z.number(),
|
|
307
316
|
requests: z.number(),
|
|
317
|
+
failures: z.number().default(0),
|
|
308
318
|
turns: z.number(),
|
|
309
319
|
lastSeen: z.number()
|
|
310
320
|
});
|
|
@@ -330,13 +340,18 @@ const usageHistoryDomain = defineDomain({
|
|
|
330
340
|
liveFirstSeq: z.record(z.string(), z.number()).optional(),
|
|
331
341
|
hoursBackfilledSessions: z.array(z.string()).optional(),
|
|
332
342
|
hoursLiveFirstSeq: z.record(z.string(), z.number()).optional(),
|
|
333
|
-
hoursReady: z.boolean().optional()
|
|
343
|
+
hoursReady: z.boolean().optional(),
|
|
344
|
+
failuresBackfilledSessions: z.array(z.string()).optional(),
|
|
345
|
+
failuresLiveFirstSeq: z.record(z.string(), z.number()).optional(),
|
|
346
|
+
failuresReady: z.boolean().optional()
|
|
334
347
|
}),
|
|
335
348
|
initial: {
|
|
336
349
|
backfilledSessions: [],
|
|
337
350
|
liveFirstSeq: {},
|
|
338
351
|
hoursBackfilledSessions: [],
|
|
339
|
-
hoursLiveFirstSeq: {}
|
|
352
|
+
hoursLiveFirstSeq: {},
|
|
353
|
+
failuresBackfilledSessions: [],
|
|
354
|
+
failuresLiveFirstSeq: {}
|
|
340
355
|
}
|
|
341
356
|
},
|
|
342
357
|
tables: {
|
|
@@ -538,6 +553,78 @@ var UsageStore = class {
|
|
|
538
553
|
this.markChain = write.then(() => void 0, () => void 0);
|
|
539
554
|
return write;
|
|
540
555
|
}
|
|
556
|
+
/** Session ids already replayed into the FAILURE counter (third cursor
|
|
557
|
+
* pair, same reason as the hour pair: the day and hour cursors were
|
|
558
|
+
* already full when the counter arrived). */
|
|
559
|
+
async failuresSeenSessions() {
|
|
560
|
+
await this.ready;
|
|
561
|
+
return new Set(this.cursor()?.failuresBackfilledSessions ?? []);
|
|
562
|
+
}
|
|
563
|
+
/** Per-session seq of the first LIVE-observed event, failure-counter share. */
|
|
564
|
+
async failuresLiveSequences() {
|
|
565
|
+
await this.ready;
|
|
566
|
+
return new Map(Object.entries(this.cursor()?.failuresLiveFirstSeq ?? {}));
|
|
567
|
+
}
|
|
568
|
+
/** Persist failure-pass replay progress (merges; serialized on the same
|
|
569
|
+
* chain as every other cursor write). */
|
|
570
|
+
async markFailuresSeenSessions(ids) {
|
|
571
|
+
const write = this.markChain.then(async () => {
|
|
572
|
+
await this.ready;
|
|
573
|
+
const value = this.requireCursor();
|
|
574
|
+
const seen = new Set(value.failuresBackfilledSessions ?? []);
|
|
575
|
+
for (const id of ids) seen.add(id);
|
|
576
|
+
await this.domainHandle?.global?.set({
|
|
577
|
+
...value,
|
|
578
|
+
backfilledSessions: value.backfilledSessions ?? [],
|
|
579
|
+
failuresBackfilledSessions: [...seen]
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
this.markChain = write.then(() => void 0, () => void 0);
|
|
583
|
+
return write;
|
|
584
|
+
}
|
|
585
|
+
/** Record the failure-pass live boundary per session (earliest wins). */
|
|
586
|
+
async markFailuresLiveSequences(entries) {
|
|
587
|
+
const write = this.markChain.then(async () => {
|
|
588
|
+
await this.ready;
|
|
589
|
+
const value = this.requireCursor();
|
|
590
|
+
const merged = { ...value.failuresLiveFirstSeq ?? {} };
|
|
591
|
+
let changed = false;
|
|
592
|
+
for (const [id, seq] of entries) {
|
|
593
|
+
const prev = merged[id];
|
|
594
|
+
if (prev === void 0 || seq < prev) {
|
|
595
|
+
merged[id] = seq;
|
|
596
|
+
changed = true;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (!changed) return;
|
|
600
|
+
await this.domainHandle?.global?.set({
|
|
601
|
+
...value,
|
|
602
|
+
backfilledSessions: value.backfilledSessions ?? [],
|
|
603
|
+
failuresLiveFirstSeq: merged
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
this.markChain = write.then(() => void 0, () => void 0);
|
|
607
|
+
return write;
|
|
608
|
+
}
|
|
609
|
+
/** Whether the failure counter covers every replayable session. */
|
|
610
|
+
async failuresReady() {
|
|
611
|
+
await this.ready;
|
|
612
|
+
return this.cursor()?.failuresReady === true;
|
|
613
|
+
}
|
|
614
|
+
/** Mark the failure history complete; the upgrade pass then never reruns. */
|
|
615
|
+
async markFailuresReady() {
|
|
616
|
+
const write = this.markChain.then(async () => {
|
|
617
|
+
await this.ready;
|
|
618
|
+
const value = this.requireCursor();
|
|
619
|
+
await this.domainHandle?.global?.set({
|
|
620
|
+
...value,
|
|
621
|
+
backfilledSessions: value.backfilledSessions ?? [],
|
|
622
|
+
failuresReady: true
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
this.markChain = write.then(() => void 0, () => void 0);
|
|
626
|
+
return write;
|
|
627
|
+
}
|
|
541
628
|
/** Drop every row and the whole cursor, re-bounding each named session at
|
|
542
629
|
* its WIPE-TIME WATERMARK (the store-rebuild escape hatch for corrupted
|
|
543
630
|
* history or attribution-logic upgrades).
|
|
@@ -565,7 +652,10 @@ var UsageStore = class {
|
|
|
565
652
|
liveFirstSeq,
|
|
566
653
|
hoursBackfilledSessions: [],
|
|
567
654
|
hoursLiveFirstSeq: { ...liveFirstSeq },
|
|
568
|
-
hoursReady: false
|
|
655
|
+
hoursReady: false,
|
|
656
|
+
failuresBackfilledSessions: [],
|
|
657
|
+
failuresLiveFirstSeq: { ...liveFirstSeq },
|
|
658
|
+
failuresReady: false
|
|
569
659
|
});
|
|
570
660
|
for (const key of [...table.keys()]) await table.delete(key);
|
|
571
661
|
for (const key of [...hourTable.keys()]) await hourTable.delete(key);
|
|
@@ -622,6 +712,22 @@ var UsageStore = class {
|
|
|
622
712
|
const target = this.targetsOf(sample);
|
|
623
713
|
await this.recordInto(this.requireHourTable(), hourRowKey(hour, target.provider, target.model), emptyHourRow(hour, target.provider, target.model), sample);
|
|
624
714
|
}
|
|
715
|
+
/** Fold ONLY the failure count of a sample into the day and hour rows. The
|
|
716
|
+
* failure upgrade pass uses this: its replay walks logs the token passes
|
|
717
|
+
* already consumed, so the tokens and the request marker must not be added
|
|
718
|
+
* a second time — only the failed-call counter may move. */
|
|
719
|
+
async recordFailure(sample) {
|
|
720
|
+
await this.record({
|
|
721
|
+
day: sample.day,
|
|
722
|
+
hour: sample.hour,
|
|
723
|
+
model: sample.model,
|
|
724
|
+
inputTokens: 0,
|
|
725
|
+
outputTokens: 0,
|
|
726
|
+
cacheReadTokens: 0,
|
|
727
|
+
cacheWriteTokens: 0,
|
|
728
|
+
failed: true
|
|
729
|
+
});
|
|
730
|
+
}
|
|
625
731
|
/** The row identity a sample lands on: turn markers use the synthetic
|
|
626
732
|
* (turns) model, unlabelled calls the (unknown) one. */
|
|
627
733
|
targetsOf(sample) {
|
|
@@ -699,6 +805,7 @@ function emptyCounters(provider, model) {
|
|
|
699
805
|
cacheReadTokens: 0,
|
|
700
806
|
cacheWriteTokens: 0,
|
|
701
807
|
requests: 0,
|
|
808
|
+
failures: 0,
|
|
702
809
|
turns: 0,
|
|
703
810
|
lastSeen: Date.now()
|
|
704
811
|
};
|
|
@@ -719,6 +826,7 @@ function accumulate(base, sample) {
|
|
|
719
826
|
return {
|
|
720
827
|
...base,
|
|
721
828
|
requests: base.requests + (sample.request ? 1 : 0),
|
|
829
|
+
failures: (base.failures ?? 0) + (sample.failed ? 1 : 0),
|
|
722
830
|
inputTokens: base.inputTokens + sample.inputTokens,
|
|
723
831
|
outputTokens: base.outputTokens + sample.outputTokens,
|
|
724
832
|
cacheReadTokens: base.cacheReadTokens + sample.cacheReadTokens,
|
|
@@ -786,6 +894,7 @@ var UsageFold = class {
|
|
|
786
894
|
break;
|
|
787
895
|
}
|
|
788
896
|
}
|
|
897
|
+
if (ev.type === "assistant/attempt") return this.attemptSample(ev, usage);
|
|
789
898
|
return usage ? this.replaceSample(ev, usage) : null;
|
|
790
899
|
}
|
|
791
900
|
if (ev.type === "compaction/summary") return this.compactionSample(ev);
|
|
@@ -828,6 +937,32 @@ var UsageFold = class {
|
|
|
828
937
|
return emitted;
|
|
829
938
|
}
|
|
830
939
|
/**
|
|
940
|
+
* Fold one `assistant/attempt` — a provider call that failed. Tokens follow
|
|
941
|
+
* the same last-report-wins rule as a committed message (the attempt's
|
|
942
|
+
* stream is the call's final word, so a usage chunk seen mid-stream is
|
|
943
|
+
* replaced or corrected, never added twice), but the call must ALWAYS be
|
|
944
|
+
* counted as one failure: `replaceSample` drops an all-zero first report,
|
|
945
|
+
* which is correct for the token buckets and would silently lose the
|
|
946
|
+
* failure. A failed call that reported no usage therefore contributes a
|
|
947
|
+
* zero-token failure marker instead of a null.
|
|
948
|
+
*/
|
|
949
|
+
attemptSample(ev, usage) {
|
|
950
|
+
const sample = this.replaceSample(ev, usage);
|
|
951
|
+
if (sample) {
|
|
952
|
+
sample.failed = true;
|
|
953
|
+
return sample;
|
|
954
|
+
}
|
|
955
|
+
return {
|
|
956
|
+
day: dayKey(ev.time),
|
|
957
|
+
hour: hourKey(ev.time),
|
|
958
|
+
inputTokens: 0,
|
|
959
|
+
outputTokens: 0,
|
|
960
|
+
cacheReadTokens: 0,
|
|
961
|
+
cacheWriteTokens: 0,
|
|
962
|
+
failed: true
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
831
966
|
* Fold one `compaction/summary` — the one place a provider call's usage
|
|
832
967
|
* reaches the log OUTSIDE the agent loop. The summarization call produces no
|
|
833
968
|
* `assistant/message` (its summary enters the surface as a `user/message`,
|
|
@@ -968,6 +1103,9 @@ var UsageCollector = class UsageCollector {
|
|
|
968
1103
|
this.store.markHoursLiveSequences(batch).catch(() => {
|
|
969
1104
|
this.status.recordFailures++;
|
|
970
1105
|
});
|
|
1106
|
+
this.store.markFailuresLiveSequences(batch).catch(() => {
|
|
1107
|
+
this.status.recordFailures++;
|
|
1108
|
+
});
|
|
971
1109
|
}
|
|
972
1110
|
});
|
|
973
1111
|
}
|
|
@@ -1091,9 +1229,16 @@ var UsageCollector = class UsageCollector {
|
|
|
1091
1229
|
async backfillHours(persistence, sessions, signal) {
|
|
1092
1230
|
await this.scan(persistence, sessions, signal, "hours");
|
|
1093
1231
|
}
|
|
1094
|
-
/**
|
|
1095
|
-
*
|
|
1096
|
-
*
|
|
1232
|
+
/** The failure upgrade pass: replay every session the FAILURE cursors have
|
|
1233
|
+
* not seen, counting only failed calls. Neither the day nor the hour
|
|
1234
|
+
* cursor covers this counter (both were full when it arrived), and the
|
|
1235
|
+
* replay must not re-add tokens or requests. */
|
|
1236
|
+
async backfillFailures(persistence, sessions, signal) {
|
|
1237
|
+
await this.scan(persistence, sessions, signal, "failures");
|
|
1238
|
+
}
|
|
1239
|
+
/** Shared replay engine behind {@link backfill}, {@link backfillHours} and
|
|
1240
|
+
* {@link backfillFailures}. `target` selects the cursor pair, the write
|
|
1241
|
+
* function, and the cursor the completed sessions land in. */
|
|
1097
1242
|
async scan(persistence, sessions, signal, target) {
|
|
1098
1243
|
if (this.status.running) return;
|
|
1099
1244
|
if (signal?.aborted) return;
|
|
@@ -1105,16 +1250,16 @@ var UsageCollector = class UsageCollector {
|
|
|
1105
1250
|
if (snap !== null && typeof snap === "object" && "header" in snap && snap.header !== null && typeof snap.header === "object" && typeof snap.header.id === "string") return snap.header;
|
|
1106
1251
|
return item;
|
|
1107
1252
|
}).filter((h) => typeof h?.id === "string" && h.id !== "");
|
|
1108
|
-
const seen = target === "hours" ? await this.store.hoursSeenSessions() : await this.store.seenSessions();
|
|
1253
|
+
const seen = target === "hours" ? await this.store.hoursSeenSessions() : target === "failures" ? await this.store.failuresSeenSessions() : await this.store.seenSessions();
|
|
1109
1254
|
const dayCursorWasEmpty = seen.size === 0;
|
|
1110
|
-
const liveSeq = target === "hours" ? await this.store.hoursLiveSequences() : await this.store.liveSequences();
|
|
1255
|
+
const liveSeq = target === "hours" ? await this.store.hoursLiveSequences() : target === "failures" ? await this.store.failuresLiveSequences() : await this.store.liveSequences();
|
|
1111
1256
|
const targets = headers.filter((h) => !seen.has(h.id));
|
|
1112
1257
|
this.status.total = targets.length;
|
|
1113
1258
|
this.status.done = 0;
|
|
1114
1259
|
const concurrency = this.options.backfillConcurrency ?? 4;
|
|
1115
1260
|
let next = 0;
|
|
1116
1261
|
const completed = [];
|
|
1117
|
-
const writeSample = target === "hours" ? (sample) => this.store.recordHours(sample) : (sample) => this.store.record(sample);
|
|
1262
|
+
const writeSample = target === "hours" ? (sample) => this.store.recordHours(sample) : target === "failures" ? (sample) => this.store.recordFailure(sample) : (sample) => this.store.record(sample);
|
|
1118
1263
|
const flushCompleted = async () => {
|
|
1119
1264
|
if (completed.length === 0) return;
|
|
1120
1265
|
const batch = completed.splice(0, completed.length);
|
|
@@ -1122,8 +1267,13 @@ var UsageCollector = class UsageCollector {
|
|
|
1122
1267
|
await this.store.markHoursSeenSessions(batch);
|
|
1123
1268
|
return;
|
|
1124
1269
|
}
|
|
1270
|
+
if (target === "failures") {
|
|
1271
|
+
await this.store.markFailuresSeenSessions(batch);
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1125
1274
|
await this.store.markSeenSessions(batch);
|
|
1126
1275
|
await this.store.markHoursSeenSessions(batch);
|
|
1276
|
+
await this.store.markFailuresSeenSessions(batch);
|
|
1127
1277
|
};
|
|
1128
1278
|
const worker = async () => {
|
|
1129
1279
|
for (;;) {
|
|
@@ -1204,7 +1354,9 @@ var UsageCollector = class UsageCollector {
|
|
|
1204
1354
|
};
|
|
1205
1355
|
await Promise.all(Array.from({ length: Math.min(concurrency, Math.max(1, targets.length)) }, worker));
|
|
1206
1356
|
await flushCompleted();
|
|
1207
|
-
|
|
1357
|
+
const clean = !signal?.aborted && this.status.error === void 0;
|
|
1358
|
+
if ((target === "hours" || dayCursorWasEmpty) && clean) await this.store.markHoursReady();
|
|
1359
|
+
if ((target === "failures" || dayCursorWasEmpty) && clean) await this.store.markFailuresReady();
|
|
1208
1360
|
} finally {
|
|
1209
1361
|
this.status.running = false;
|
|
1210
1362
|
}
|
|
@@ -1565,6 +1717,16 @@ async function aggregateRange(store, from, to, granularity = "day", llm) {
|
|
|
1565
1717
|
cacheWriteTokens: 0,
|
|
1566
1718
|
request: true
|
|
1567
1719
|
};
|
|
1720
|
+
for (let i = 0; i < (row.failures ?? 0); i++) yield {
|
|
1721
|
+
day,
|
|
1722
|
+
hour,
|
|
1723
|
+
model: row.model,
|
|
1724
|
+
inputTokens: 0,
|
|
1725
|
+
outputTokens: 0,
|
|
1726
|
+
cacheReadTokens: 0,
|
|
1727
|
+
cacheWriteTokens: 0,
|
|
1728
|
+
failed: true
|
|
1729
|
+
};
|
|
1568
1730
|
for (let i = 0; i < row.turns; i++) yield {
|
|
1569
1731
|
day,
|
|
1570
1732
|
hour,
|
|
@@ -1953,6 +2115,28 @@ function apply(ctx, config = {}) {
|
|
|
1953
2115
|
scanController = controller;
|
|
1954
2116
|
await collector.backfillHours(ctx.sessionPersistence, ctx.sessions, controller.signal);
|
|
1955
2117
|
};
|
|
2118
|
+
const ensureFailureHistory = async () => {
|
|
2119
|
+
await store.readyPromise();
|
|
2120
|
+
if (await store.failuresReady()) return;
|
|
2121
|
+
if (!await store.hasAnyDayRows()) return;
|
|
2122
|
+
if (collector.status.error !== void 0) return;
|
|
2123
|
+
const previous = await store.failuresLiveSequences();
|
|
2124
|
+
const boundaries = /* @__PURE__ */ new Map();
|
|
2125
|
+
for (const handle of ctx.sessions.list()) {
|
|
2126
|
+
const seq = handle.seq;
|
|
2127
|
+
if (typeof seq === "number" && Number.isFinite(seq) && seq >= 0) {
|
|
2128
|
+
boundaries.set(handle.id, seq);
|
|
2129
|
+
continue;
|
|
2130
|
+
}
|
|
2131
|
+
const old = previous.get(handle.id);
|
|
2132
|
+
boundaries.set(handle.id, typeof old === "number" ? old : -1);
|
|
2133
|
+
}
|
|
2134
|
+
await store.markFailuresLiveSequences(boundaries);
|
|
2135
|
+
scanController?.abort();
|
|
2136
|
+
const controller = new AbortController();
|
|
2137
|
+
scanController = controller;
|
|
2138
|
+
await collector.backfillFailures(ctx.sessionPersistence, ctx.sessions, controller.signal);
|
|
2139
|
+
};
|
|
1956
2140
|
ctx.effect(() => {
|
|
1957
2141
|
collector.start();
|
|
1958
2142
|
const onEvent = (session) => {
|
|
@@ -1963,6 +2147,7 @@ function apply(ctx, config = {}) {
|
|
|
1963
2147
|
(async () => {
|
|
1964
2148
|
await rescan();
|
|
1965
2149
|
await ensureHourHistory();
|
|
2150
|
+
await ensureFailureHistory();
|
|
1966
2151
|
})();
|
|
1967
2152
|
return () => {
|
|
1968
2153
|
scanController?.abort();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots';
|
|
2
|
+
import { type ContextBreakdownLike, type ContextPressureLike } from './context-meter-core.ts';
|
|
3
|
+
export declare const CONTEXT_LOCALE_NS = "tokenTotalContext";
|
|
4
|
+
declare const keys: readonly ["label", "used", "system", "tools", "messages", "aria", "thousand", "million"];
|
|
5
|
+
export type ContextLocaleKey = typeof keys[number];
|
|
6
|
+
export declare const CONTEXT_LOCALES: Record<string, Record<ContextLocaleKey, string>>;
|
|
7
|
+
type ProjectionMap = {
|
|
8
|
+
contextPressure: ContextPressureLike;
|
|
9
|
+
contextBreakdown: ContextBreakdownLike;
|
|
10
|
+
};
|
|
11
|
+
type UseContextProjection = <K extends keyof ProjectionMap & string>(key: K) => ProjectionMap[K] | undefined;
|
|
12
|
+
export interface ContextUsageEntryProps extends PropsLocale<typeof CONTEXT_LOCALE_NS> {
|
|
13
|
+
useProjection: UseContextProjection;
|
|
14
|
+
installContextTakeover?: (anchor: HTMLElement) => () => void;
|
|
15
|
+
}
|
|
16
|
+
export declare function ContextUsageEntry({ useProjection, t, installContextTakeover }: ContextUsageEntryProps): JSX.Element | null;
|
|
17
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
18
|
+
interface LocaleNamespaceMap {
|
|
19
|
+
tokenTotalContext: ContextLocaleKey;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface ContextPressureLike {
|
|
2
|
+
projectedTokens?: number;
|
|
3
|
+
pressureTokens?: number;
|
|
4
|
+
contextWindow?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface ContextBreakdownLike {
|
|
7
|
+
systemTokens: number;
|
|
8
|
+
toolsTokens: number;
|
|
9
|
+
messageTokens: number;
|
|
10
|
+
}
|
|
11
|
+
export interface ContextOccupancy {
|
|
12
|
+
percent: number;
|
|
13
|
+
usedTokens: number;
|
|
14
|
+
contextWindow: number;
|
|
15
|
+
}
|
|
16
|
+
/** Mirrors the host meter's occupancy calculation exactly. */
|
|
17
|
+
export declare function contextOccupancy(pressure: ContextPressureLike | undefined): ContextOccupancy | null;
|
|
18
|
+
export declare function contextBreakdownTotal(breakdown: ContextBreakdownLike | undefined): number;
|
|
19
|
+
export declare function contextBreakdownSegments(percent: number, breakdown: ContextBreakdownLike | undefined): Array<{
|
|
20
|
+
key: string;
|
|
21
|
+
width: number;
|
|
22
|
+
}>;
|
|
23
|
+
export declare function formatContextTokens(value: number, units: {
|
|
24
|
+
thousand: string;
|
|
25
|
+
million: string;
|
|
26
|
+
}): string;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** The host ContextMeter is hard-coded beside composer.dock, not a slot.
|
|
2
|
+
* Suppress only that recognizable sibling while our replacement is mounted;
|
|
3
|
+
* never hide a whole dock, slot wrapper, or another plugin's readout. */
|
|
4
|
+
export declare function installContextTakeover(anchor: HTMLElement): () => void;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
/** The locale namespace this plugin owns. */
|
|
8
8
|
export declare const LOCALE_NS = "tokenTotal";
|
|
9
9
|
/** The typed translator key union for the panel's locale seat. */
|
|
10
|
-
export type TokenTotalKey = 'nav' | 'back' | 'range' | 'rangePreset.today' | 'rangePreset.3' | 'rangePreset.7' | 'rangePreset.30' | 'rangePreset.90' | 'rangeCustom' | 'from' | 'to' | 'refresh' | 'loading' | 'tokens' | 'tokensHint' | 'tokensUnit' | 'inputTokens' | 'outputTokens' | 'cacheTokens' | 'cachedTokens' | 'sessions' | 'requests' | 'activeDays' | 'cacheRate' | 'cacheRateHint' | 'cacheHitRate' | 'hitRateLegend' | 'topModel' | 'topModelHint' | 'heatmap' | 'heatLess' | 'heatMore' | 'dailyTrend' | 'hourlyTrend' | 'trendLimited' | 'usageRanking' | 'byModel' | 'byProvider' | 'tokensColumn' | 'cacheRateColumn' | 'providerModels' | 'other' | 'total' | 'percent' | 'asOf' | 'empty' | 'stats.counts' | 'stats.tokensPerSecond' | 'stats.cacheHit' | 'stats.cacheMiss' | 'stats.dialog.title' | 'stats.dialog.usageTitle' | 'stats.dialog.llmTime' | 'stats.dialog.toolTime' | 'stats.dialog.ttft' | 'stats.dialog.speed' | 'stats.dialog.main' | 'stats.dialog.subagents' | 'stats.dialog.subagentCount' | 'stats.dialog.treeTotal' | 'message.turnUsage.count' | 'message.turnUsage.cacheHit' | 'message.turnUsage.input' | 'message.turnUsage.cacheRead' | 'message.turnUsage.cacheWrite' | 'message.turnUsage.output';
|
|
10
|
+
export type TokenTotalKey = 'nav' | 'back' | 'range' | 'rangePreset.today' | 'rangePreset.3' | 'rangePreset.7' | 'rangePreset.30' | 'rangePreset.90' | 'rangeCustom' | 'from' | 'to' | 'refresh' | 'loading' | 'tokens' | 'tokensHint' | 'tokensUnit' | 'inputTokens' | 'outputTokens' | 'cacheTokens' | 'cachedTokens' | 'sessions' | 'requests' | 'requestsFailed' | 'activeDays' | 'cacheRate' | 'cacheRateHint' | 'cacheHitRate' | 'hitRateLegend' | 'topModel' | 'topModelHint' | 'heatmap' | 'heatLess' | 'heatMore' | 'dailyTrend' | 'hourlyTrend' | 'trendLimited' | 'usageRanking' | 'byModel' | 'byProvider' | 'tokensColumn' | 'cacheRateColumn' | 'providerModels' | 'other' | 'total' | 'percent' | 'asOf' | 'empty' | 'stats.counts' | 'stats.tokensPerSecond' | 'stats.cacheHit' | 'stats.cacheMiss' | 'stats.dialog.title' | 'stats.dialog.usageTitle' | 'stats.dialog.llmTime' | 'stats.dialog.toolTime' | 'stats.dialog.ttft' | 'stats.dialog.speed' | 'stats.dialog.main' | 'stats.dialog.subagents' | 'stats.dialog.subagentCount' | 'stats.dialog.treeTotal' | 'message.turnUsage.count' | 'message.turnUsage.cacheHit' | 'message.turnUsage.input' | 'message.turnUsage.cacheRead' | 'message.turnUsage.cacheWrite' | 'message.turnUsage.output';
|
|
11
11
|
export declare const en: Record<TokenTotalKey, string>;
|
|
12
12
|
export declare const zh: Record<TokenTotalKey, string>;
|
|
13
13
|
export declare const zhTW: Record<TokenTotalKey, string>;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/** Pure billing helpers shared by the session-tree hook and composer pills. */
|
|
2
2
|
import type { SessionUsageTotals, SessionUsageTree } from '../wire.ts';
|
|
3
|
-
/**
|
|
4
|
-
* it: that would count the main agent twice. The log is only the fallback. */
|
|
3
|
+
/** Add the root and descendants once; tree.total is already that sum. */
|
|
5
4
|
export declare function combinedTotals(tree: SessionUsageTree, self?: SessionUsageTotals): SessionUsageTotals;
|
|
6
5
|
/** One consistent split for the pill, cache hit rate and every dialog row. */
|
|
7
6
|
export declare function sessionUsageForDisplay(native: SessionUsageTotals | undefined, tree: SessionUsageTree | null): SessionUsageTree;
|
|
@@ -2,7 +2,7 @@ import type { SessionUsageTotals, SessionUsageTree } from '../wire.ts';
|
|
|
2
2
|
export { billedTotal, cacheHitPercentOf, combinedTotals, hasAnyTokens } from './session-usage-core.ts';
|
|
3
3
|
export interface SessionTreeReading {
|
|
4
4
|
tree: SessionUsageTree | null;
|
|
5
|
-
/**
|
|
5
|
+
/** Authoritative log-based main + all descendant usage after the tree loads. */
|
|
6
6
|
totals: SessionUsageTotals | null;
|
|
7
7
|
}
|
|
8
8
|
export declare function useSessionUsage(sessionId: string | undefined, signature: string): SessionTreeReading;
|
package/lib/types/collector.d.ts
CHANGED
|
@@ -74,6 +74,17 @@ export declare class UsageFold {
|
|
|
74
74
|
* carries no (new) usage or turn marker. */
|
|
75
75
|
fold(ev: UsageSessionEvent): UsageSample | null;
|
|
76
76
|
private replaceSample;
|
|
77
|
+
/**
|
|
78
|
+
* Fold one `assistant/attempt` — a provider call that failed. Tokens follow
|
|
79
|
+
* the same last-report-wins rule as a committed message (the attempt's
|
|
80
|
+
* stream is the call's final word, so a usage chunk seen mid-stream is
|
|
81
|
+
* replaced or corrected, never added twice), but the call must ALWAYS be
|
|
82
|
+
* counted as one failure: `replaceSample` drops an all-zero first report,
|
|
83
|
+
* which is correct for the token buckets and would silently lose the
|
|
84
|
+
* failure. A failed call that reported no usage therefore contributes a
|
|
85
|
+
* zero-token failure marker instead of a null.
|
|
86
|
+
*/
|
|
87
|
+
private attemptSample;
|
|
77
88
|
/**
|
|
78
89
|
* Fold one `compaction/summary` — the one place a provider call's usage
|
|
79
90
|
* reaches the log OUTSIDE the agent loop. The summarization call produces no
|
|
@@ -200,8 +211,13 @@ export declare class UsageCollector {
|
|
|
200
211
|
* untouched, so a user who upgraded never loses day history because a
|
|
201
212
|
* session log was pruned. */
|
|
202
213
|
backfillHours(persistence: UsageSessionPersistence, sessions: UsageSessionStore, signal?: AbortSignal): Promise<void>;
|
|
203
|
-
/**
|
|
204
|
-
*
|
|
205
|
-
*
|
|
214
|
+
/** The failure upgrade pass: replay every session the FAILURE cursors have
|
|
215
|
+
* not seen, counting only failed calls. Neither the day nor the hour
|
|
216
|
+
* cursor covers this counter (both were full when it arrived), and the
|
|
217
|
+
* replay must not re-add tokens or requests. */
|
|
218
|
+
backfillFailures(persistence: UsageSessionPersistence, sessions: UsageSessionStore, signal?: AbortSignal): Promise<void>;
|
|
219
|
+
/** Shared replay engine behind {@link backfill}, {@link backfillHours} and
|
|
220
|
+
* {@link backfillFailures}. `target` selects the cursor pair, the write
|
|
221
|
+
* function, and the cursor the completed sessions land in. */
|
|
206
222
|
private scan;
|
|
207
223
|
}
|
package/lib/types/query.d.ts
CHANGED
|
@@ -41,6 +41,14 @@ export interface UsageSample {
|
|
|
41
41
|
cacheWriteTokens: number;
|
|
42
42
|
turn?: boolean;
|
|
43
43
|
request?: boolean;
|
|
44
|
+
/** True when this sample also counts one FAILED provider call. The host
|
|
45
|
+
* appends `assistant/attempt` (instead of `assistant/message`) when a
|
|
46
|
+
* call's stream ends with an error or is cancelled before any content is
|
|
47
|
+
* committed, so that event is the exact boundary of a failed call. The
|
|
48
|
+
* marker is ORTHOGONAL to `request` and to the buckets: a failed call was
|
|
49
|
+
* still opened by its own `step/start` (so it is already one request) and
|
|
50
|
+
* a stream that died mid-flight may still report usage. */
|
|
51
|
+
failed?: boolean;
|
|
44
52
|
}
|
|
45
53
|
export interface RangeFilter {
|
|
46
54
|
from: string;
|
package/lib/types/store.d.ts
CHANGED
|
@@ -23,6 +23,11 @@ export interface UsageCounters {
|
|
|
23
23
|
cacheReadTokens: number;
|
|
24
24
|
cacheWriteTokens: number;
|
|
25
25
|
requests: number;
|
|
26
|
+
/** Provider calls in the bucket that FAILED (error / cancelled before any
|
|
27
|
+
* content): a subset of `requests`, never larger than it. Rows persisted
|
|
28
|
+
* before the counter existed may lack it; the schema normalizes that to 0
|
|
29
|
+
* and every subsequent fold writes the number back. */
|
|
30
|
+
failures: number;
|
|
26
31
|
turns: number;
|
|
27
32
|
lastSeen: number;
|
|
28
33
|
}
|
|
@@ -43,6 +48,7 @@ export declare const usageDayRowSchema: z.ZodObject<{
|
|
|
43
48
|
cacheReadTokens: z.ZodNumber;
|
|
44
49
|
cacheWriteTokens: z.ZodNumber;
|
|
45
50
|
requests: z.ZodNumber;
|
|
51
|
+
failures: z.ZodDefault<z.ZodNumber>;
|
|
46
52
|
turns: z.ZodNumber;
|
|
47
53
|
lastSeen: z.ZodNumber;
|
|
48
54
|
}, z.core.$strip>;
|
|
@@ -55,6 +61,7 @@ export declare const usageHourRowSchema: z.ZodObject<{
|
|
|
55
61
|
cacheReadTokens: z.ZodNumber;
|
|
56
62
|
cacheWriteTokens: z.ZodNumber;
|
|
57
63
|
requests: z.ZodNumber;
|
|
64
|
+
failures: z.ZodDefault<z.ZodNumber>;
|
|
58
65
|
turns: z.ZodNumber;
|
|
59
66
|
lastSeen: z.ZodNumber;
|
|
60
67
|
}, z.core.$strip>;
|
|
@@ -81,12 +88,17 @@ export declare const usageHistoryDomain: {
|
|
|
81
88
|
hoursBackfilledSessions: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
82
89
|
hoursLiveFirstSeq: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
|
|
83
90
|
hoursReady: z.ZodOptional<z.ZodBoolean>;
|
|
91
|
+
failuresBackfilledSessions: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
92
|
+
failuresLiveFirstSeq: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
|
|
93
|
+
failuresReady: z.ZodOptional<z.ZodBoolean>;
|
|
84
94
|
}, z.core.$strip>;
|
|
85
95
|
initial: {
|
|
86
96
|
backfilledSessions: string[];
|
|
87
97
|
liveFirstSeq: {};
|
|
88
98
|
hoursBackfilledSessions: string[];
|
|
89
99
|
hoursLiveFirstSeq: {};
|
|
100
|
+
failuresBackfilledSessions: string[];
|
|
101
|
+
failuresLiveFirstSeq: {};
|
|
90
102
|
};
|
|
91
103
|
};
|
|
92
104
|
tables: {
|
|
@@ -160,6 +172,21 @@ export declare class UsageStore {
|
|
|
160
172
|
hoursReady(): Promise<boolean>;
|
|
161
173
|
/** Mark the hour history complete; the upgrade pass then never reruns. */
|
|
162
174
|
markHoursReady(): Promise<void>;
|
|
175
|
+
/** Session ids already replayed into the FAILURE counter (third cursor
|
|
176
|
+
* pair, same reason as the hour pair: the day and hour cursors were
|
|
177
|
+
* already full when the counter arrived). */
|
|
178
|
+
failuresSeenSessions(): Promise<Set<string>>;
|
|
179
|
+
/** Per-session seq of the first LIVE-observed event, failure-counter share. */
|
|
180
|
+
failuresLiveSequences(): Promise<Map<string, number>>;
|
|
181
|
+
/** Persist failure-pass replay progress (merges; serialized on the same
|
|
182
|
+
* chain as every other cursor write). */
|
|
183
|
+
markFailuresSeenSessions(ids: Iterable<string>): Promise<void>;
|
|
184
|
+
/** Record the failure-pass live boundary per session (earliest wins). */
|
|
185
|
+
markFailuresLiveSequences(entries: Iterable<readonly [string, number]>): Promise<void>;
|
|
186
|
+
/** Whether the failure counter covers every replayable session. */
|
|
187
|
+
failuresReady(): Promise<boolean>;
|
|
188
|
+
/** Mark the failure history complete; the upgrade pass then never reruns. */
|
|
189
|
+
markFailuresReady(): Promise<void>;
|
|
163
190
|
/** Drop every row and the whole cursor, re-bounding each named session at
|
|
164
191
|
* its WIPE-TIME WATERMARK (the store-rebuild escape hatch for corrupted
|
|
165
192
|
* history or attribution-logic upgrades).
|
|
@@ -198,6 +225,11 @@ export declare class UsageStore {
|
|
|
198
225
|
* day cursors are already advanced, so replaying through `record()` would
|
|
199
226
|
* double every day row. */
|
|
200
227
|
recordHours(sample: UsageSample): Promise<void>;
|
|
228
|
+
/** Fold ONLY the failure count of a sample into the day and hour rows. The
|
|
229
|
+
* failure upgrade pass uses this: its replay walks logs the token passes
|
|
230
|
+
* already consumed, so the tokens and the request marker must not be added
|
|
231
|
+
* a second time — only the failed-call counter may move. */
|
|
232
|
+
recordFailure(sample: UsageSample): Promise<void>;
|
|
201
233
|
/** The row identity a sample lands on: turn markers use the synthetic
|
|
202
234
|
* (turns) model, unlabelled calls the (unknown) one. */
|
|
203
235
|
private targetsOf;
|
package/lib/types/wire.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export interface DailyTokenUsage {
|
|
|
17
17
|
byModel: Record<string, number>;
|
|
18
18
|
byProvider: Record<string, number>;
|
|
19
19
|
requests: number;
|
|
20
|
+
failures: number;
|
|
20
21
|
turns: number;
|
|
21
22
|
cacheHit: number;
|
|
22
23
|
cacheMiss: number;
|
|
@@ -70,6 +71,12 @@ export interface TokenTotalRange {
|
|
|
70
71
|
/** Cached prompt tokens: cache reads + cache writes (the cache bucket of `tokens`). */
|
|
71
72
|
cacheTokens: number;
|
|
72
73
|
requests: number;
|
|
74
|
+
/** Provider calls in the range that FAILED: the stream ended with an error
|
|
75
|
+
* or the call was cancelled before any content was committed. A subset of
|
|
76
|
+
* `requests` (a failed call was still one call), and — like `requests` —
|
|
77
|
+
* independent of the token buckets: a stream that died mid-flight may
|
|
78
|
+
* still have reported usage. */
|
|
79
|
+
failures: number;
|
|
73
80
|
turns: number;
|
|
74
81
|
cacheHit: number;
|
|
75
82
|
cacheMiss: number;
|
package/package.json
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
.root { display: inline-flex; align-items: center; min-width: 0; font: inherit; font-variant-numeric: tabular-nums; line-height: inherit; }
|
|
2
|
+
.trigger { display: inline-flex; align-items: center; gap: 6px; box-sizing: border-box; max-width: 100%; padding: 1px 8px; border: 0; border-radius: 24px; background: transparent; color: var(--dsw-alias-label-tertiary); font: inherit; line-height: inherit; white-space: nowrap; cursor: pointer; }
|
|
3
|
+
.trigger svg { flex: none; }
|
|
4
|
+
.track { fill: none; stroke: var(--dsw-alias-separator-primary); stroke-width: 1.5; }
|
|
5
|
+
.fill { fill: none; stroke: var(--dsw-alias-label-tertiary); stroke-width: 2; stroke-linecap: round; }
|
|
6
|
+
.trigger:hover, .trigger[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); }
|
|
7
|
+
.panel { position: fixed; z-index: 1100; box-sizing: border-box; width: max-content; min-width: min(300px, calc(100vw - 24px)); max-width: min(440px, calc(100vw - 24px)); padding: 16px; border-radius: 12px; background: var(--dsw-specific-menu); backdrop-filter: var(--dsw-menu-backdrop-filter); box-shadow: var(--dsw-elevation-prominent); color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; }
|
|
8
|
+
.header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; color: var(--dsw-alias-label-primary); font-weight: 500; }
|
|
9
|
+
.percent { font-variant-numeric: tabular-nums; }
|
|
10
|
+
.figures { margin-left: auto; color: var(--dsw-alias-label-tertiary); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
11
|
+
.bar { display: flex; width: 100%; height: 8px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-separator-primary); }
|
|
12
|
+
.segment { height: 100%; }
|
|
13
|
+
.systemTokens { background: var(--dsw-alias-label-primary); }
|
|
14
|
+
.toolsTokens { background: var(--dsw-alias-label-secondary); }
|
|
15
|
+
.messageTokens { background: var(--dsw-alias-label-tertiary); }
|
|
16
|
+
.rows { display: grid; gap: 6px; margin: 12px 0 0; color: var(--dsw-alias-label-tertiary); }
|
|
17
|
+
.row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
|
18
|
+
.row dt { display: flex; align-items: center; gap: 8px; }
|
|
19
|
+
.row dd { margin: 0; color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }
|
|
20
|
+
.swatch { width: 8px; height: 8px; border-radius: 2px; }
|