@itookit/dsht 0.3.2 → 0.3.3
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.i18n.yaml +2 -2
- package/README.md +16 -15
- package/README.zh.md +18 -17
- package/dist/cli/index.js +9 -12
- package/dist/controller/controller.d.ts +10 -1
- package/dist/controller/controller.js +12 -2
- package/dist/cost/config.d.ts +17 -0
- package/dist/cost/config.js +68 -0
- package/dist/cost/controller.d.ts +9 -2
- package/dist/cost/controller.js +10 -2
- package/dist/cost/index.d.ts +5 -4
- package/dist/cost/index.js +4 -3
- package/dist/cost/ledger-files.d.ts +4 -8
- package/dist/cost/ledger-files.js +46 -71
- package/dist/cost/ledger.d.ts +33 -20
- package/dist/cost/ledger.js +78 -68
- package/dist/cost/pricing.d.ts +55 -17
- package/dist/cost/pricing.js +126 -44
- package/dist/cost/records.js +18 -5
- package/dist/cost/types.d.ts +34 -18
- package/dist/cost/types.js +4 -0
- package/dist/session/navigation.d.ts +26 -0
- package/dist/session/navigation.js +48 -0
- package/dist/session/transcript.d.ts +48 -6
- package/dist/session/transcript.js +117 -14
- package/dist/storage/files.d.ts +8 -0
- package/dist/storage/files.js +17 -0
- package/dist/storage/heap-snapshot.d.ts +19 -0
- package/dist/storage/heap-snapshot.js +29 -0
- package/dist/storage/index.d.ts +2 -1
- package/dist/storage/index.js +2 -1
- package/dist/ui/app.js +68 -4
- package/dist/ui/chat/history-view.d.ts +5 -1
- package/dist/ui/chat/history-view.js +9 -4
- package/dist/ui/chat/status.d.ts +16 -4
- package/dist/ui/chat/status.js +100 -25
- package/dist/ui/commands/parse.d.ts +3 -0
- package/dist/ui/commands/parse.js +5 -0
- package/dist/ui/commands/registry.js +1 -0
- package/dist/ui/dialogs/cost.js +3 -3
- package/package.json +2 -2
package/dist/cost/controller.js
CHANGED
|
@@ -14,15 +14,23 @@ export class CostController {
|
|
|
14
14
|
this.ledger = ledger;
|
|
15
15
|
this.host = host;
|
|
16
16
|
}
|
|
17
|
-
/** Scan immediately, then once a minute while online.
|
|
17
|
+
/** Scan immediately, then once a minute while online.
|
|
18
|
+
*
|
|
19
|
+
* Every generation starts with no skip bookkeeping, because the host keeps running while this
|
|
20
|
+
* client is not connected: an update time this client already recorded cannot prove that nothing
|
|
21
|
+
* happened during the gap, so the first scan of the generation reads every session's history
|
|
22
|
+
* again. Within that generation the recorded times keep the minute timer from re-reading idle
|
|
23
|
+
* sessions.
|
|
24
|
+
*/
|
|
18
25
|
start() {
|
|
19
26
|
if (this.timer)
|
|
20
27
|
return;
|
|
28
|
+
this.updates.clear();
|
|
21
29
|
void this.refresh().catch(() => undefined);
|
|
22
30
|
this.timer = setInterval(() => { if (this.host.online())
|
|
23
31
|
void this.refresh().catch(() => undefined); }, REFRESH_INTERVAL_MS);
|
|
24
32
|
}
|
|
25
|
-
/** Stop the timer and wait for an in-flight scan; the ledger keeps its
|
|
33
|
+
/** Stop the timer and wait for an in-flight scan; the ledger keeps its folded totals. */
|
|
26
34
|
async stop() {
|
|
27
35
|
clearInterval(this.timer);
|
|
28
36
|
this.timer = undefined;
|
package/dist/cost/index.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
/** Cost domain: price tables, record folding, the
|
|
1
|
+
/** Cost domain: price tables, record folding, the folded ledger, the scanner and its controller. */
|
|
2
2
|
export { CostController } from './controller.ts';
|
|
3
3
|
export type { CostHost } from './controller.ts';
|
|
4
4
|
export { CostLedger, costText } from './ledger.ts';
|
|
5
|
-
export {
|
|
5
|
+
export { loadPrices } from './config.ts';
|
|
6
|
+
export { candidates, canonicalModel, chargeFor, costDay, DEFAULT_PRICES, isUncorrectedSeed, priceAt, PRICES_REVISION, PRICING_ENGINE_VERSION, pricesDigest, pricesFrom } from './pricing.ts';
|
|
6
7
|
export { costRecords, foldSamples } from './records.ts';
|
|
7
8
|
export { costAddresses, sessionCostHistory } from './scanner.ts';
|
|
8
|
-
export { MISSING_USAGE } from './types.ts';
|
|
9
|
-
export type {
|
|
9
|
+
export { MISSING_TIME, MISSING_USAGE, UNSUPPORTED_USAGE } from './types.ts';
|
|
10
|
+
export type { ChargeSample, CostTotal, Coverage, DayTotal, PriceDecision, PriceVersion, Rates, SavedCost, Usage } from './types.ts';
|
package/dist/cost/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
/** Cost domain: price tables, record folding, the
|
|
1
|
+
/** Cost domain: price tables, record folding, the folded ledger, the scanner and its controller. */
|
|
2
2
|
export { CostController } from "./controller.js";
|
|
3
3
|
export { CostLedger, costText } from "./ledger.js";
|
|
4
|
-
export {
|
|
4
|
+
export { loadPrices } from "./config.js";
|
|
5
|
+
export { candidates, canonicalModel, chargeFor, costDay, DEFAULT_PRICES, isUncorrectedSeed, priceAt, PRICES_REVISION, PRICING_ENGINE_VERSION, pricesDigest, pricesFrom } from "./pricing.js";
|
|
5
6
|
export { costRecords, foldSamples } from "./records.js";
|
|
6
7
|
export { costAddresses, sessionCostHistory } from "./scanner.js";
|
|
7
|
-
export { MISSING_USAGE } from "./types.js";
|
|
8
|
+
export { MISSING_TIME, MISSING_USAGE, UNSUPPORTED_USAGE } from "./types.js";
|
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
import type { SavedCost } from './types.ts';
|
|
2
|
-
/** Load every session's
|
|
3
|
-
*
|
|
4
|
-
* A file of another generation, a file with an unreadable shape, and a cut file superseded by the
|
|
5
|
-
* newer name all describe work the next scan rebuilds, so loading removes them; the newest slice
|
|
6
|
-
* of a superseded name is rewritten under the fixed one first. Files this unit does not own are
|
|
7
|
-
* left where they are.
|
|
2
|
+
/** Load every session's stored totals; a file this build cannot read is left for the next scan.
|
|
8
3
|
* @param directory - Origin-scoped ledger directory, or undefined when persistence is disabled.
|
|
9
4
|
* @returns The newest saved slice per session identity.
|
|
10
5
|
*/
|
|
11
6
|
export declare function loadLedgers(directory: string | undefined): Promise<Map<string, SavedCost>>;
|
|
12
|
-
/** Write one session
|
|
7
|
+
/** Write one session's totals unless the directory already holds a newer scan.
|
|
13
8
|
*
|
|
14
9
|
* The file is the system of record across processes, so a scan that opened an older snapshot must
|
|
15
|
-
* not replace a cut another scan already persisted
|
|
10
|
+
* not replace a cut another scan already persisted, and a process holding older decision rules must
|
|
11
|
+
* not seal its totals over newer ones.
|
|
16
12
|
* @param directory - Origin-scoped ledger directory.
|
|
17
13
|
* @param saved - Complete slice to persist.
|
|
18
14
|
* @returns Whether the directory now holds this slice.
|
|
@@ -1,21 +1,14 @@
|
|
|
1
|
-
/** Atomic per-session persistence for
|
|
1
|
+
/** Atomic per-session persistence for folded cost totals. */
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { ensureDirectory, listEntries, readText,
|
|
5
|
-
/** Current on-disk ledger generation.
|
|
6
|
-
const LEDGER_VERSION =
|
|
7
|
-
/**
|
|
8
|
-
const OWNED_FILE = /^[0-9a-f]{64}(?:-\d+)?\.json$/;
|
|
9
|
-
/** The one file that holds a session's newest cut; the cut itself lives inside the file. */
|
|
4
|
+
import { ensureDirectory, listEntries, readText, writePrivateFile } from "../storage/index.js";
|
|
5
|
+
/** Current on-disk ledger generation. A file of another generation is ignored: the next scan rebuilds it. */
|
|
6
|
+
const LEDGER_VERSION = 3;
|
|
7
|
+
/** The one file that holds a session's totals: the fixed per-session name. */
|
|
10
8
|
function ledgerName(sessionId) {
|
|
11
9
|
return `${createHash('sha256').update(sessionId).digest('hex')}.json`;
|
|
12
10
|
}
|
|
13
|
-
/** Load every session's
|
|
14
|
-
*
|
|
15
|
-
* A file of another generation, a file with an unreadable shape, and a cut file superseded by the
|
|
16
|
-
* newer name all describe work the next scan rebuilds, so loading removes them; the newest slice
|
|
17
|
-
* of a superseded name is rewritten under the fixed one first. Files this unit does not own are
|
|
18
|
-
* left where they are.
|
|
11
|
+
/** Load every session's stored totals; a file this build cannot read is left for the next scan.
|
|
19
12
|
* @param directory - Origin-scoped ledger directory, or undefined when persistence is disabled.
|
|
20
13
|
* @returns The newest saved slice per session identity.
|
|
21
14
|
*/
|
|
@@ -24,9 +17,8 @@ export async function loadLedgers(directory) {
|
|
|
24
17
|
if (!directory)
|
|
25
18
|
return sessions;
|
|
26
19
|
await ensureDirectory(directory);
|
|
27
|
-
const superseded = [];
|
|
28
20
|
for (const name of await listEntries(directory)) {
|
|
29
|
-
if (
|
|
21
|
+
if (!/^[0-9a-f]{64}\.json$/.test(name))
|
|
30
22
|
continue;
|
|
31
23
|
const path = join(directory, name);
|
|
32
24
|
const raw = await readText(path);
|
|
@@ -34,25 +26,19 @@ export async function loadLedgers(directory) {
|
|
|
34
26
|
if (raw === undefined)
|
|
35
27
|
continue;
|
|
36
28
|
const saved = parseLedger(raw);
|
|
37
|
-
|
|
38
|
-
|
|
29
|
+
// A slice is a projection of the host log: one this build cannot use costs a rescan, not data.
|
|
30
|
+
if (saved === undefined)
|
|
39
31
|
continue;
|
|
40
|
-
}
|
|
41
32
|
if ((sessions.get(saved.sessionId)?.cut ?? -2) <= saved.cut)
|
|
42
33
|
sessions.set(saved.sessionId, saved);
|
|
43
|
-
if (name !== ledgerName(saved.sessionId))
|
|
44
|
-
superseded.push({ path, sessionId: saved.sessionId });
|
|
45
|
-
}
|
|
46
|
-
for (const { path, sessionId } of superseded) {
|
|
47
|
-
await writePrivateFile(join(directory, ledgerName(sessionId)), JSON.stringify(sessions.get(sessionId)) + '\n');
|
|
48
|
-
await removeFile(path);
|
|
49
34
|
}
|
|
50
35
|
return sessions;
|
|
51
36
|
}
|
|
52
|
-
/** Write one session
|
|
37
|
+
/** Write one session's totals unless the directory already holds a newer scan.
|
|
53
38
|
*
|
|
54
39
|
* The file is the system of record across processes, so a scan that opened an older snapshot must
|
|
55
|
-
* not replace a cut another scan already persisted
|
|
40
|
+
* not replace a cut another scan already persisted, and a process holding older decision rules must
|
|
41
|
+
* not seal its totals over newer ones.
|
|
56
42
|
* @param directory - Origin-scoped ledger directory.
|
|
57
43
|
* @param saved - Complete slice to persist.
|
|
58
44
|
* @returns Whether the directory now holds this slice.
|
|
@@ -60,25 +46,15 @@ export async function loadLedgers(directory) {
|
|
|
60
46
|
export async function saveLedger(directory, saved) {
|
|
61
47
|
const path = join(directory, ledgerName(saved.sessionId));
|
|
62
48
|
const existing = await readText(path);
|
|
63
|
-
if (existing !== undefined
|
|
64
|
-
|
|
49
|
+
if (existing !== undefined) {
|
|
50
|
+
const previous = parseLedger(existing);
|
|
51
|
+
if (previous !== undefined && (previous.cut > saved.cut || previous.engine > saved.engine))
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
65
54
|
await writePrivateFile(path, JSON.stringify(saved) + '\n');
|
|
66
55
|
return true;
|
|
67
56
|
}
|
|
68
|
-
/**
|
|
69
|
-
* @param raw - File contents read from the ledger directory.
|
|
70
|
-
* @returns The persisted cut.
|
|
71
|
-
*/
|
|
72
|
-
function persistedCut(raw) {
|
|
73
|
-
try {
|
|
74
|
-
return parseLedger(raw)?.cut ?? -1;
|
|
75
|
-
}
|
|
76
|
-
// A file of another generation or an unreadable shape carries no decision, so the new cut replaces it.
|
|
77
|
-
catch {
|
|
78
|
-
return -1;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
/** Validate one persisted ledger; another generation or an unreadable shape is absent. */
|
|
57
|
+
/** Validate one persisted slice; another generation or an unreadable shape is absent. */
|
|
82
58
|
function parseLedger(raw) {
|
|
83
59
|
let value;
|
|
84
60
|
try {
|
|
@@ -92,37 +68,36 @@ function parseLedger(raw) {
|
|
|
92
68
|
const record = value;
|
|
93
69
|
if (record.version !== LEDGER_VERSION)
|
|
94
70
|
return undefined;
|
|
95
|
-
if (typeof record.sessionId !== 'string' || !Number.isSafeInteger(record.cut)
|
|
71
|
+
if (typeof record.sessionId !== 'string' || !Number.isSafeInteger(record.cut))
|
|
96
72
|
throw new Error('Invalid cost ledger');
|
|
97
|
-
|
|
98
|
-
const charges = record.charges;
|
|
99
|
-
if (!charges.every(validCharge))
|
|
73
|
+
if (!Number.isSafeInteger(record.engine) || typeof record.catalog !== 'string')
|
|
100
74
|
throw new Error('Invalid cost ledger');
|
|
101
|
-
|
|
75
|
+
const total = validTotal(record.total);
|
|
76
|
+
const day = validDay(record.day);
|
|
77
|
+
if (total === undefined || day === undefined)
|
|
78
|
+
throw new Error('Invalid cost ledger');
|
|
79
|
+
if (!Array.isArray(record.unpriced) || record.unpriced.some(reason => typeof reason !== 'string'))
|
|
80
|
+
throw new Error('Invalid cost ledger');
|
|
81
|
+
return { version: LEDGER_VERSION, sessionId: record.sessionId, cut: record.cut, engine: record.engine,
|
|
82
|
+
catalog: record.catalog, total, day, unpriced: record.unpriced };
|
|
102
83
|
}
|
|
103
|
-
/**
|
|
104
|
-
|
|
84
|
+
/** Accept one stored subtotal only when the amount is finite and the counts are whole.
|
|
85
|
+
* Costs are fractional, so only the counts are required to be integers. */
|
|
86
|
+
function validTotal(value) {
|
|
105
87
|
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
106
|
-
return
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (
|
|
113
|
-
return
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (typeof c.usage !== 'object' || c.usage === null || Array.isArray(c.usage))
|
|
122
|
-
return false;
|
|
123
|
-
const buckets = Object.values(c.usage);
|
|
124
|
-
if (buckets.length === 0 || buckets.some(n => typeof n !== 'number' || !Number.isSafeInteger(n) || n < 0))
|
|
125
|
-
return false;
|
|
126
|
-
}
|
|
127
|
-
return true;
|
|
88
|
+
return undefined;
|
|
89
|
+
const total = value;
|
|
90
|
+
const amount = total.amount;
|
|
91
|
+
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0)
|
|
92
|
+
return undefined;
|
|
93
|
+
const counts = [total.unknown, total.records];
|
|
94
|
+
if (!counts.every(count => typeof count === 'number' && Number.isSafeInteger(count) && count >= 0))
|
|
95
|
+
return undefined;
|
|
96
|
+
return { amount, unknown: total.unknown, records: total.records };
|
|
97
|
+
}
|
|
98
|
+
/** Accept one stored day bucket only when it also names the Beijing day it covers. */
|
|
99
|
+
function validDay(value) {
|
|
100
|
+
const total = validTotal(value);
|
|
101
|
+
const day = value?.day;
|
|
102
|
+
return total === undefined || typeof day !== 'string' || day === '' ? undefined : { day, ...total };
|
|
128
103
|
}
|
package/dist/cost/ledger.d.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** CNY estimates folded from host history: per-session totals, re-decided by every scan. */
|
|
2
2
|
import type { ObjectValue } from '../transport/wire.ts';
|
|
3
3
|
import { type CostTotal, type Coverage, type PriceVersion } from './types.ts';
|
|
4
|
-
/** Per-origin cache of
|
|
4
|
+
/** Per-origin cache of folded session totals; every scan replaces a session at its cut. */
|
|
5
5
|
export declare class CostLedger {
|
|
6
6
|
readonly prices: PriceVersion[];
|
|
7
7
|
readonly directory?: string | undefined;
|
|
8
|
+
/** Whether the table came from a file the user maintains, rather than the shipped one. */
|
|
9
|
+
readonly customPrices: boolean;
|
|
8
10
|
private sessions;
|
|
9
11
|
private totals;
|
|
12
|
+
private readonly catalog;
|
|
10
13
|
scannedAt?: number;
|
|
11
14
|
scanning: boolean;
|
|
12
15
|
error: string;
|
|
@@ -16,47 +19,57 @@ export declare class CostLedger {
|
|
|
16
19
|
pages: number;
|
|
17
20
|
events: number;
|
|
18
21
|
};
|
|
19
|
-
constructor(prices?: PriceVersion[], directory?: string | undefined
|
|
20
|
-
/**
|
|
22
|
+
constructor(prices?: PriceVersion[], directory?: string | undefined,
|
|
23
|
+
/** Whether the table came from a file the user maintains, rather than the shipped one. */
|
|
24
|
+
customPrices?: boolean);
|
|
25
|
+
/** Cached totals count as complete; only a failed scan or an empty ledger is partial.
|
|
21
26
|
* @returns Coverage of the current totals, so callers can mark them without re-deriving the rule.
|
|
22
27
|
*/
|
|
23
28
|
get coverage(): Coverage;
|
|
24
|
-
/** Load the newest
|
|
29
|
+
/** Load the newest cut per session; a stored total is read as it was decided. */
|
|
25
30
|
load(): Promise<void>;
|
|
26
31
|
/** Replace one session using all billing events through the opening snapshot cut.
|
|
27
32
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
33
|
+
* The fold is the projection: every sample is decided again with the table loaded now, so a
|
|
34
|
+
* corrected table reaches history on the next scan and a request no table covered yet is priced
|
|
35
|
+
* as soon as one does. Only the totals are kept, and the cut and engine keep an older scan from
|
|
36
|
+
* replacing a newer one.
|
|
30
37
|
* @param sessionId - Host session identity.
|
|
31
38
|
* @param cut - Opening cursor, preventing a stale scan from overwriting a newer scan.
|
|
32
39
|
* @param events - Minimal events returned by `costRecords`, across all history pages.
|
|
40
|
+
* @param now - Clock that names the calendar day the day bucket covers.
|
|
33
41
|
*/
|
|
34
|
-
replace(sessionId: string, cut: number, events: ObjectValue[]): Promise<void>;
|
|
42
|
+
replace(sessionId: string, cut: number, events: ObjectValue[], now?: number): Promise<void>;
|
|
35
43
|
/** Count the retained ledger so a memory sample can separate it from the transcript window.
|
|
36
|
-
* @returns Sessions and
|
|
44
|
+
* @returns Sessions, requests and unpriced requests currently held.
|
|
37
45
|
*/
|
|
38
46
|
summary(): {
|
|
39
47
|
sessions: number;
|
|
40
|
-
|
|
48
|
+
records: number;
|
|
41
49
|
unpriced: number;
|
|
42
50
|
};
|
|
43
51
|
/** Whether this session has a complete cached scan.
|
|
44
|
-
* @param sessionId - Selected session identity.
|
|
45
|
-
* @returns True when a complete scan is available.
|
|
52
|
+
* @param sessionId - Selected session identity, which may be unset before one is picked.
|
|
53
|
+
* @returns True when a complete scan is available, narrowing the identity to a string.
|
|
46
54
|
*/
|
|
47
|
-
hasSession(sessionId
|
|
55
|
+
hasSession(sessionId: string | undefined): sessionId is string;
|
|
48
56
|
/** Describe unpriced model/usage combinations without exposing conversation content.
|
|
49
57
|
* @returns Unique reasons across cached sessions.
|
|
50
58
|
*/
|
|
51
59
|
missing(): string[];
|
|
52
|
-
/**
|
|
53
|
-
* @param sessionId -
|
|
54
|
-
* @
|
|
55
|
-
* @param now - Clock used for date attribution.
|
|
56
|
-
* @returns Known subtotal, unpriceable count, and estimated count; an estimated record always
|
|
57
|
-
* names an amount, but a dated range only adds the records it can place inside that range.
|
|
60
|
+
/** One session's stored totals.
|
|
61
|
+
* @param sessionId - Session identity to report.
|
|
62
|
+
* @returns The total a scan folded for it, or zeros when no scan has covered it.
|
|
58
63
|
*/
|
|
59
|
-
total(sessionId
|
|
64
|
+
total(sessionId: string): CostTotal;
|
|
65
|
+
/** Every session's requests on one Beijing calendar day.
|
|
66
|
+
*
|
|
67
|
+
* A slice keeps only the day its last scan ran on, so another day reads as nothing spent today
|
|
68
|
+
* rather than as the last day that was scanned.
|
|
69
|
+
* @param now - Clock that names the day to report.
|
|
70
|
+
* @returns The day's total across cached sessions.
|
|
71
|
+
*/
|
|
72
|
+
today(now?: number): CostTotal;
|
|
60
73
|
}
|
|
61
74
|
/** Compact estimates retain an asterisk whenever a subtotal is not exact.
|
|
62
75
|
* @param total - Summary from the ledger.
|
package/dist/cost/ledger.js
CHANGED
|
@@ -1,37 +1,30 @@
|
|
|
1
|
-
import { chargeFor, costDay, DEFAULT_PRICES } from "./pricing.js";
|
|
1
|
+
import { chargeFor, costDay, DEFAULT_PRICES, pricesDigest, PRICING_ENGINE_VERSION } from "./pricing.js";
|
|
2
2
|
import { foldSamples } from "./records.js";
|
|
3
3
|
import { loadLedgers, saveLedger } from "./ledger-files.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
return charge.amount !== undefined || (charge.reason !== undefined && charge.reason !== MISSING_USAGE);
|
|
8
|
-
}
|
|
9
|
-
/** Attach the current table's decision, or reuse the decision an earlier scan recorded.
|
|
10
|
-
*
|
|
11
|
-
* A sample without usage has not finished reporting tokens, so it stays open for the next scan;
|
|
12
|
-
* every other decision — priced, unpriced, or estimated — is final.
|
|
13
|
-
*/
|
|
14
|
-
function decide(prices, sample, previous) {
|
|
15
|
-
if (previous !== undefined && (sealed(previous) || sample.usage === undefined))
|
|
16
|
-
return previous;
|
|
17
|
-
return { ...sample, ...chargeFor(prices, sample.provider, sample.model, sample.time, sample.usage) };
|
|
18
|
-
}
|
|
19
|
-
/** Per-origin cache of decided request charges; each scan replaces a session at a fixed cut. */
|
|
4
|
+
/** Reasons one slice keeps at most, so a broken table cannot grow the ledger without bound. */
|
|
5
|
+
const UNPRICED_LIMIT = 8;
|
|
6
|
+
/** Per-origin cache of folded session totals; every scan replaces a session at its cut. */
|
|
20
7
|
export class CostLedger {
|
|
21
8
|
prices;
|
|
22
9
|
directory;
|
|
10
|
+
customPrices;
|
|
23
11
|
sessions = new Map();
|
|
24
12
|
totals = new Map();
|
|
13
|
+
catalog;
|
|
25
14
|
scannedAt;
|
|
26
15
|
scanning = false;
|
|
27
16
|
error = '';
|
|
28
17
|
/** Work the last completed scan performed, so a memory sample can attribute its allocation. */
|
|
29
18
|
lastScan;
|
|
30
|
-
constructor(prices = DEFAULT_PRICES, directory
|
|
19
|
+
constructor(prices = DEFAULT_PRICES, directory,
|
|
20
|
+
/** Whether the table came from a file the user maintains, rather than the shipped one. */
|
|
21
|
+
customPrices = false) {
|
|
31
22
|
this.prices = prices;
|
|
32
23
|
this.directory = directory;
|
|
24
|
+
this.customPrices = customPrices;
|
|
25
|
+
this.catalog = pricesDigest(prices);
|
|
33
26
|
}
|
|
34
|
-
/** Cached
|
|
27
|
+
/** Cached totals count as complete; only a failed scan or an empty ledger is partial.
|
|
35
28
|
* @returns Coverage of the current totals, so callers can mark them without re-deriving the rule.
|
|
36
29
|
*/
|
|
37
30
|
get coverage() {
|
|
@@ -41,29 +34,48 @@ export class CostLedger {
|
|
|
41
34
|
return 'partial';
|
|
42
35
|
return this.scannedAt !== undefined || this.sessions.size > 0 ? 'complete' : 'partial';
|
|
43
36
|
}
|
|
44
|
-
/** Load the newest
|
|
37
|
+
/** Load the newest cut per session; a stored total is read as it was decided. */
|
|
45
38
|
async load() {
|
|
46
39
|
this.totals.clear();
|
|
47
|
-
|
|
48
|
-
if ((this.sessions.get(sessionId)?.cut ?? -2) <= saved.cut)
|
|
49
|
-
this.sessions.set(sessionId, saved);
|
|
50
|
-
}
|
|
40
|
+
this.sessions = await loadLedgers(this.directory);
|
|
51
41
|
}
|
|
52
42
|
/** Replace one session using all billing events through the opening snapshot cut.
|
|
53
43
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
44
|
+
* The fold is the projection: every sample is decided again with the table loaded now, so a
|
|
45
|
+
* corrected table reaches history on the next scan and a request no table covered yet is priced
|
|
46
|
+
* as soon as one does. Only the totals are kept, and the cut and engine keep an older scan from
|
|
47
|
+
* replacing a newer one.
|
|
56
48
|
* @param sessionId - Host session identity.
|
|
57
49
|
* @param cut - Opening cursor, preventing a stale scan from overwriting a newer scan.
|
|
58
50
|
* @param events - Minimal events returned by `costRecords`, across all history pages.
|
|
51
|
+
* @param now - Clock that names the calendar day the day bucket covers.
|
|
59
52
|
*/
|
|
60
|
-
async replace(sessionId, cut, events) {
|
|
53
|
+
async replace(sessionId, cut, events, now = Date.now()) {
|
|
61
54
|
const current = this.sessions.get(sessionId);
|
|
62
55
|
if ((current?.cut ?? -2) > cut)
|
|
63
56
|
return;
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
const
|
|
57
|
+
const day = costDay(now);
|
|
58
|
+
const total = { amount: 0, unknown: 0, records: 0 };
|
|
59
|
+
const today = { day, amount: 0, unknown: 0, records: 0 };
|
|
60
|
+
const unpriced = new Set();
|
|
61
|
+
for (const sample of foldSamples(events)) {
|
|
62
|
+
const decision = chargeFor(this.prices, sample.provider, sample.model, sample.time, sample.usage);
|
|
63
|
+
// A request belongs to the day it settled on, so the day bucket only counts the requests of
|
|
64
|
+
// the calendar day this scan is running on; another day reads as nothing spent today. A request
|
|
65
|
+
// with no settlement time belongs to no day, so it is counted as unknown wherever it is read.
|
|
66
|
+
const buckets = sample.time === undefined || costDay(sample.time) === day ? [total, today] : [total];
|
|
67
|
+
for (const bucket of buckets) {
|
|
68
|
+
bucket.records++;
|
|
69
|
+
if (decision.amount === undefined)
|
|
70
|
+
bucket.unknown++;
|
|
71
|
+
else
|
|
72
|
+
bucket.amount += decision.amount;
|
|
73
|
+
}
|
|
74
|
+
if (decision.amount === undefined && unpriced.size < UNPRICED_LIMIT)
|
|
75
|
+
unpriced.add(`${sample.provider}/${sample.model}: ${decision.reason}`);
|
|
76
|
+
}
|
|
77
|
+
const saved = { version: 3, sessionId, cut, engine: PRICING_ENGINE_VERSION, catalog: this.catalog,
|
|
78
|
+
total, day: today, unpriced: [...unpriced] };
|
|
67
79
|
// Another process may have persisted a newer cut of this session since it was last read.
|
|
68
80
|
if (this.directory && !await saveLedger(this.directory, saved))
|
|
69
81
|
return;
|
|
@@ -71,61 +83,59 @@ export class CostLedger {
|
|
|
71
83
|
this.totals.clear();
|
|
72
84
|
}
|
|
73
85
|
/** Count the retained ledger so a memory sample can separate it from the transcript window.
|
|
74
|
-
* @returns Sessions and
|
|
86
|
+
* @returns Sessions, requests and unpriced requests currently held.
|
|
75
87
|
*/
|
|
76
88
|
summary() {
|
|
77
|
-
let
|
|
89
|
+
let records = 0, unpriced = 0;
|
|
78
90
|
for (const session of this.sessions.values()) {
|
|
79
|
-
|
|
80
|
-
unpriced += session.
|
|
91
|
+
records += session.total.records;
|
|
92
|
+
unpriced += session.total.unknown;
|
|
81
93
|
}
|
|
82
|
-
return { sessions: this.sessions.size,
|
|
94
|
+
return { sessions: this.sessions.size, records, unpriced };
|
|
83
95
|
}
|
|
84
96
|
/** Whether this session has a complete cached scan.
|
|
85
|
-
* @param sessionId - Selected session identity.
|
|
86
|
-
* @returns True when a complete scan is available.
|
|
97
|
+
* @param sessionId - Selected session identity, which may be unset before one is picked.
|
|
98
|
+
* @returns True when a complete scan is available, narrowing the identity to a string.
|
|
87
99
|
*/
|
|
88
100
|
hasSession(sessionId) { return sessionId !== undefined && this.sessions.has(sessionId); }
|
|
89
101
|
/** Describe unpriced model/usage combinations without exposing conversation content.
|
|
90
102
|
* @returns Unique reasons across cached sessions.
|
|
91
103
|
*/
|
|
92
|
-
missing() {
|
|
93
|
-
|
|
104
|
+
missing() { return [...new Set([...this.sessions.values()].flatMap(s => s.unpriced))]; }
|
|
105
|
+
/** One session's stored totals.
|
|
106
|
+
* @param sessionId - Session identity to report.
|
|
107
|
+
* @returns The total a scan folded for it, or zeros when no scan has covered it.
|
|
108
|
+
*/
|
|
109
|
+
total(sessionId) {
|
|
110
|
+
const cached = this.totals.get(`s:${sessionId}`);
|
|
111
|
+
if (cached)
|
|
112
|
+
return cached;
|
|
113
|
+
const session = this.sessions.get(sessionId);
|
|
114
|
+
const result = session === undefined ? { amount: 0, unknown: 0, records: 0 } : { ...session.total };
|
|
115
|
+
this.totals.set(`s:${sessionId}`, result);
|
|
116
|
+
return result;
|
|
94
117
|
}
|
|
95
|
-
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* @
|
|
100
|
-
*
|
|
118
|
+
/** Every session's requests on one Beijing calendar day.
|
|
119
|
+
*
|
|
120
|
+
* A slice keeps only the day its last scan ran on, so another day reads as nothing spent today
|
|
121
|
+
* rather than as the last day that was scanned.
|
|
122
|
+
* @param now - Clock that names the day to report.
|
|
123
|
+
* @returns The day's total across cached sessions.
|
|
101
124
|
*/
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
const cached = this.totals.get(
|
|
125
|
+
today(now = Date.now()) {
|
|
126
|
+
const day = costDay(now);
|
|
127
|
+
const cached = this.totals.get(`d:${day}`);
|
|
105
128
|
if (cached)
|
|
106
129
|
return cached;
|
|
107
|
-
const result = { amount: 0, unknown: 0,
|
|
108
|
-
const end = costDay(now);
|
|
109
|
-
const start = costDay(now - ((days ?? 1) - 1) * 86400_000);
|
|
130
|
+
const result = { amount: 0, unknown: 0, records: 0 };
|
|
110
131
|
for (const session of this.sessions.values()) {
|
|
111
|
-
if (
|
|
132
|
+
if (session.day.day !== day)
|
|
112
133
|
continue;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
result.records++;
|
|
117
|
-
if (charge.amount === undefined) {
|
|
118
|
-
result.unknown++;
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
const dated = days === undefined || charge.time !== undefined;
|
|
122
|
-
if (charge.estimated === true || !dated)
|
|
123
|
-
result.estimated++;
|
|
124
|
-
if (dated)
|
|
125
|
-
result.amount += charge.amount;
|
|
126
|
-
}
|
|
134
|
+
result.amount += session.day.amount;
|
|
135
|
+
result.unknown += session.day.unknown;
|
|
136
|
+
result.records += session.day.records;
|
|
127
137
|
}
|
|
128
|
-
this.totals.set(
|
|
138
|
+
this.totals.set(`d:${day}`, result);
|
|
129
139
|
return result;
|
|
130
140
|
}
|
|
131
141
|
}
|
|
@@ -133,4 +143,4 @@ export class CostLedger {
|
|
|
133
143
|
* @param total - Summary from the ledger.
|
|
134
144
|
* @returns Yuan amount and incompleteness marker.
|
|
135
145
|
*/
|
|
136
|
-
export function costText(total) { return `~¥${total.amount.toFixed(4)}${total.unknown
|
|
146
|
+
export function costText(total) { return `~¥${total.amount.toFixed(4)}${total.unknown ? '*' : ''}`; }
|