@itookit/dsht 0.3.2 → 0.3.4
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 +21 -20
- package/README.zh.md +21 -20
- package/dist/cli/dsht.d.ts +2 -0
- package/dist/cli/dsht.js +125 -0
- package/dist/cli/index.js +16 -126
- package/dist/controller/controller.d.ts +18 -1
- package/dist/controller/controller.js +24 -2
- package/dist/controller/perf-measures.d.ts +34 -0
- package/dist/controller/perf-measures.js +78 -0
- 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/controller.d.ts +16 -0
- package/dist/session/controller.js +33 -0
- package/dist/session/navigation.d.ts +80 -0
- package/dist/session/navigation.js +107 -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 +116 -10
- 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 +112 -32
- 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/dist/ui/dialogs/index.d.ts +6 -2
- package/dist/ui/dialogs/index.js +3 -3
- package/dist/ui/dialogs/picker.d.ts +21 -3
- package/dist/ui/dialogs/picker.js +37 -5
- package/dist/ui/input/input.d.ts +18 -3
- package/dist/ui/input/input.js +61 -22
- package/dist/ui/input/viewport.d.ts +96 -0
- package/dist/ui/input/viewport.js +173 -0
- package/package.json +3 -3
|
@@ -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 ? '*' : ''}`; }
|
package/dist/cost/pricing.d.ts
CHANGED
|
@@ -1,48 +1,86 @@
|
|
|
1
1
|
import { type PriceDecision, type PriceVersion, type Rates, type Usage } from './types.ts';
|
|
2
2
|
export declare const DEFAULT_PRICES: PriceVersion[];
|
|
3
|
+
/** Revision of the pricing decision rules, recorded with the totals they decided.
|
|
4
|
+
*
|
|
5
|
+
* Bump it whenever the rules change what an amount would be — the matching of a model name, the
|
|
6
|
+
* token buckets an amount covers, or the timestamp it is priced at — so a stored total can be traced
|
|
7
|
+
* to the rules that produced it. Version 1 matched a model by the substring `pro` and priced a
|
|
8
|
+
* request with no settlement time at the cheapest off-peak rate.
|
|
9
|
+
*/
|
|
10
|
+
export declare const PRICING_ENGINE_VERSION = 2;
|
|
11
|
+
/** Revision of the shipped table, recorded beside a seeded file so a correction can replace it. */
|
|
12
|
+
export declare const PRICES_REVISION = "2026-09-12";
|
|
13
|
+
/** Whether a table is the seed an earlier revision wrote, which a corrected ship must replace.
|
|
14
|
+
*
|
|
15
|
+
* `prices.json` overrides the shipped table, so an install seeded before the Flash rates were
|
|
16
|
+
* corrected keeps charging 1.5x for input and 2.5x for cache reads for as long as that file lives.
|
|
17
|
+
* Only an exact match to the superseded revision qualifies, so a rate the user chose is never rewritten.
|
|
18
|
+
* @param prices - Table loaded from the configuration file.
|
|
19
|
+
* @returns True when every entry carries the superseded revision's rates.
|
|
20
|
+
*/
|
|
21
|
+
export declare function isUncorrectedSeed(prices: readonly PriceVersion[]): boolean;
|
|
3
22
|
/** Validate user-maintained price versions, rejecting ambiguous overlapping intervals.
|
|
4
23
|
* @param value - Parsed prices.json array.
|
|
5
24
|
* @returns Price versions with validated rates and schedules.
|
|
6
25
|
*/
|
|
7
26
|
export declare function pricesFrom(value: unknown): PriceVersion[];
|
|
8
|
-
/** Return the calendar date
|
|
27
|
+
/** Return the Beijing calendar date a request is attributed to.
|
|
9
28
|
* @param time - Epoch milliseconds.
|
|
10
29
|
* @returns Beijing calendar date, YYYY-MM-DD.
|
|
11
30
|
*/
|
|
12
31
|
export declare function costDay(time: number): string;
|
|
13
|
-
/**
|
|
32
|
+
/** Canonical form of a model name before matching.
|
|
33
|
+
*
|
|
34
|
+
* The host reports names that differ from the published table by width, case, surrounding space, or
|
|
35
|
+
* the CJK full stop a display path can substitute for a period. Normalizing here keeps the table
|
|
36
|
+
* readable and keeps a name variant from silently missing its entry. NFKC does not fold the CJK
|
|
37
|
+
* stops, so they are mapped explicitly.
|
|
38
|
+
* @param model - Model name exactly as the recorded request reported it.
|
|
39
|
+
* @returns The name in the form the table is matched against.
|
|
40
|
+
*/
|
|
41
|
+
export declare function canonicalModel(model: string): string;
|
|
42
|
+
/** Candidate versions for one request, in the order the table is searched: the exact model, then
|
|
43
|
+
* the aliases a version declares. A name the table does not cover stays unpriced rather than
|
|
44
|
+
* falling back to a family guess, because guessing a rate is indistinguishable from a wrong one.
|
|
14
45
|
* @param prices - Validated versions.
|
|
15
46
|
* @param provider - Provider identity from the recorded request.
|
|
16
|
-
* @param model - Recorded model name
|
|
17
|
-
* @
|
|
18
|
-
* @returns Matching price version and per-million-token rates, if known.
|
|
47
|
+
* @param model - Recorded model name.
|
|
48
|
+
* @returns Matching versions, each with the rule that matched it.
|
|
19
49
|
*/
|
|
20
|
-
export declare function
|
|
50
|
+
export declare function candidates(prices: PriceVersion[], provider: string, model: string): {
|
|
21
51
|
price: PriceVersion;
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
52
|
+
matchedBy: 'exact' | 'alias';
|
|
53
|
+
}[];
|
|
54
|
+
/** Stable identity of a loaded price table.
|
|
55
|
+
*
|
|
56
|
+
* The identity a price version carries can be edited in place while keeping its `id` — which is how
|
|
57
|
+
* a corrected table once kept charging superseded rates under one id — so a stored total records a
|
|
58
|
+
* digest of the whole table it was decided from, not only the version names.
|
|
59
|
+
* @param prices - Price versions loaded for this process.
|
|
60
|
+
* @returns Short digest of the table.
|
|
61
|
+
*/
|
|
62
|
+
export declare function pricesDigest(prices: readonly PriceVersion[]): string;
|
|
63
|
+
/** Select a price by event time, applying half-open local peak windows.
|
|
27
64
|
* @param prices - Validated versions.
|
|
28
65
|
* @param provider - Provider identity from the recorded request.
|
|
29
66
|
* @param model - Recorded model name.
|
|
30
|
-
* @
|
|
67
|
+
* @param time - Recorded settlement timestamp used as the billing instant.
|
|
68
|
+
* @returns Matching price version, the rule that matched it, and its per-million-token rates.
|
|
31
69
|
*/
|
|
32
|
-
export declare function
|
|
70
|
+
export declare function priceAt(prices: PriceVersion[], provider: string, model: string, time: number): {
|
|
33
71
|
price: PriceVersion;
|
|
34
72
|
rates: Rates;
|
|
73
|
+
matchedBy: 'exact' | 'alias';
|
|
35
74
|
} | undefined;
|
|
36
75
|
/** Decide the amount for one request sample using the table loaded at decision time.
|
|
37
76
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* undecided, because its request has not finished reporting tokens yet.
|
|
77
|
+
* A decision is a number or a reason; nothing about the rates that produced it is kept, because the
|
|
78
|
+
* ledger stores totals rather than requests and the next scan decides the sample again.
|
|
41
79
|
* @param prices - Validated versions currently loaded.
|
|
42
80
|
* @param provider - Provider identity from the recorded request.
|
|
43
81
|
* @param model - Recorded model name.
|
|
44
82
|
* @param time - Recorded settlement timestamp, when the host logged one.
|
|
45
83
|
* @param usage - Disjoint token buckets, when the host reported valid counts.
|
|
46
|
-
* @returns The
|
|
84
|
+
* @returns The amount, or the reason no amount exists.
|
|
47
85
|
*/
|
|
48
86
|
export declare function chargeFor(prices: PriceVersion[], provider: string, model: string, time: number | undefined, usage: Usage | undefined): PriceDecision;
|