@itookit/dsht 0.3.0 → 0.3.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.i18n.yaml +2 -2
- package/README.md +30 -15
- package/README.zh.md +30 -15
- package/dist/catalog/controller.d.ts +32 -0
- package/dist/catalog/controller.js +88 -0
- package/dist/catalog/index.d.ts +2 -0
- package/dist/catalog/index.js +2 -0
- package/dist/{cli.js → cli/index.js} +40 -24
- package/dist/controller/connection.d.ts +80 -0
- package/dist/controller/connection.js +190 -0
- package/dist/controller/controller.d.ts +260 -0
- package/dist/controller/controller.js +362 -0
- package/dist/controller/index.d.ts +5 -0
- package/dist/controller/index.js +3 -0
- package/dist/controller/memory-log.d.ts +35 -0
- package/dist/controller/memory-log.js +95 -0
- package/dist/cost/controller.d.ts +34 -0
- package/dist/cost/controller.js +107 -0
- package/dist/cost/index.d.ts +9 -0
- package/dist/cost/index.js +7 -0
- package/dist/cost/ledger-files.d.ts +20 -0
- package/dist/cost/ledger-files.js +128 -0
- package/dist/cost/ledger.d.ts +65 -0
- package/dist/cost/ledger.js +136 -0
- package/dist/cost/pricing.d.ts +48 -0
- package/dist/cost/pricing.js +141 -0
- package/dist/cost/records.d.ts +17 -0
- package/dist/cost/records.js +66 -0
- package/dist/cost/scanner.d.ts +22 -0
- package/dist/cost/scanner.js +95 -0
- package/dist/cost/types.d.ts +69 -0
- package/dist/cost/types.js +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/session/connection-view.d.ts +18 -0
- package/dist/session/connection-view.js +1 -0
- package/dist/{controller.d.ts → session/controller.d.ts} +102 -136
- package/dist/session/controller.js +616 -0
- package/dist/session/export-html.d.ts +9 -0
- package/dist/session/export-html.js +39 -0
- package/dist/{export.d.ts → session/export.d.ts} +1 -1
- package/dist/{export.js → session/export.js} +6 -20
- package/dist/{history.d.ts → session/history.d.ts} +17 -0
- package/dist/{history.js → session/history.js} +163 -2
- package/dist/session/index.d.ts +17 -0
- package/dist/session/index.js +10 -0
- package/dist/session/markdown.d.ts +39 -0
- package/dist/session/markdown.js +255 -0
- package/dist/session/math.d.ts +11 -0
- package/dist/session/math.js +82 -0
- package/dist/{navigation.d.ts → session/navigation.d.ts} +1 -1
- package/dist/{navigation.js → session/navigation.js} +1 -1
- package/dist/{references.js → session/references.js} +1 -1
- package/dist/{telemetry.d.ts → session/telemetry.d.ts} +1 -1
- package/dist/{telemetry.js → session/telemetry.js} +1 -1
- package/dist/{transcript.d.ts → session/transcript.d.ts} +23 -1
- package/dist/{transcript.js → session/transcript.js} +36 -18
- package/dist/session/types.d.ts +18 -0
- package/dist/session/types.js +2 -0
- package/dist/state.d.ts +41 -0
- package/dist/state.js +9 -0
- package/dist/storage/directories.d.ts +14 -0
- package/dist/storage/directories.js +24 -0
- package/dist/storage/files.d.ts +43 -0
- package/dist/storage/files.js +135 -0
- package/dist/storage/index.d.ts +3 -0
- package/dist/storage/index.js +3 -0
- package/dist/transport/auth.js +65 -0
- package/dist/transport/host.d.ts +13 -0
- package/dist/transport/host.js +1 -0
- package/dist/ui/app.d.ts +11 -0
- package/dist/ui/app.js +726 -0
- package/dist/ui/chat/header.d.ts +11 -0
- package/dist/ui/chat/header.js +14 -0
- package/dist/{history-view.d.ts → ui/chat/history-view.d.ts} +1 -1
- package/dist/{history-view.js → ui/chat/history-view.js} +3 -3
- package/dist/ui/chat/status.d.ts +79 -0
- package/dist/ui/chat/status.js +311 -0
- package/dist/ui/chat/viewport.d.ts +17 -0
- package/dist/ui/chat/viewport.js +14 -0
- package/dist/ui/commands/parse.d.ts +96 -0
- package/dist/ui/commands/parse.js +121 -0
- package/dist/ui/commands/registry.d.ts +33 -0
- package/dist/ui/commands/registry.js +72 -0
- package/dist/ui/copy-mode.d.ts +4 -0
- package/dist/ui/copy-mode.js +6 -0
- package/dist/{cost-view.d.ts → ui/dialogs/cost.d.ts} +1 -1
- package/dist/{cost-view.js → ui/dialogs/cost.js} +3 -3
- package/dist/ui/dialogs/index.d.ts +120 -0
- package/dist/ui/dialogs/index.js +113 -0
- package/dist/ui/dialogs/picker.d.ts +18 -0
- package/dist/ui/dialogs/picker.js +38 -0
- package/dist/ui/frozen.d.ts +8 -0
- package/dist/ui/frozen.js +7 -0
- package/dist/ui/input/references.d.ts +12 -0
- package/dist/ui/input/references.js +15 -0
- package/dist/ui/mount.d.ts +6 -0
- package/dist/ui/mount.js +11 -0
- package/dist/{theme.d.ts → ui/theme/index.d.ts} +1 -1
- package/package.json +19 -13
- package/dist/app.d.ts +0 -21
- package/dist/app.js +0 -805
- package/dist/auth.js +0 -108
- package/dist/controller.js +0 -961
- package/dist/cost.d.ts +0 -119
- package/dist/cost.js +0 -313
- package/dist/status.d.ts +0 -28
- package/dist/status.js +0 -157
- /package/dist/{cli.d.ts → cli/index.d.ts} +0 -0
- /package/dist/{memory.d.ts → session/memory.d.ts} +0 -0
- /package/dist/{memory.js → session/memory.js} +0 -0
- /package/dist/{references.d.ts → session/references.d.ts} +0 -0
- /package/dist/{auth.d.ts → transport/auth.d.ts} +0 -0
- /package/dist/{client.d.ts → transport/client.d.ts} +0 -0
- /package/dist/{client.js → transport/client.js} +0 -0
- /package/dist/{endpoint.d.ts → transport/endpoint.d.ts} +0 -0
- /package/dist/{endpoint.js → transport/endpoint.js} +0 -0
- /package/dist/{wire.d.ts → transport/wire.d.ts} +0 -0
- /package/dist/{wire.js → transport/wire.js} +0 -0
- /package/dist/{input-history.d.ts → ui/input/history.d.ts} +0 -0
- /package/dist/{input-history.js → ui/input/history.js} +0 -0
- /package/dist/{input.d.ts → ui/input/input.d.ts} +0 -0
- /package/dist/{input.js → ui/input/input.js} +0 -0
- /package/dist/{mouse.d.ts → ui/input/mouse.d.ts} +0 -0
- /package/dist/{mouse.js → ui/input/mouse.js} +0 -0
- /package/dist/{theme.js → ui/theme/index.js} +0 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { SavedCost } from './types.ts';
|
|
2
|
+
/** Load every session's newest cut, leaving one fixed file per session.
|
|
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.
|
|
8
|
+
* @param directory - Origin-scoped ledger directory, or undefined when persistence is disabled.
|
|
9
|
+
* @returns The newest saved slice per session identity.
|
|
10
|
+
*/
|
|
11
|
+
export declare function loadLedgers(directory: string | undefined): Promise<Map<string, SavedCost>>;
|
|
12
|
+
/** Write one session cut unless the directory already holds a newer one.
|
|
13
|
+
*
|
|
14
|
+
* 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.
|
|
16
|
+
* @param directory - Origin-scoped ledger directory.
|
|
17
|
+
* @param saved - Complete slice to persist.
|
|
18
|
+
* @returns Whether the directory now holds this slice.
|
|
19
|
+
*/
|
|
20
|
+
export declare function saveLedger(directory: string, saved: SavedCost): Promise<boolean>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/** Atomic per-session persistence for the immutable charge ledger. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { ensureDirectory, listEntries, readText, removeFile, writePrivateFile } from "../storage/index.js";
|
|
5
|
+
/** Current on-disk ledger generation. Files of another generation are ignored, not migrated. */
|
|
6
|
+
const LEDGER_VERSION = 2;
|
|
7
|
+
/** Every file this unit owns: the fixed per-session name, or the `<session>-<cut>.json` it replaced. */
|
|
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. */
|
|
10
|
+
function ledgerName(sessionId) {
|
|
11
|
+
return `${createHash('sha256').update(sessionId).digest('hex')}.json`;
|
|
12
|
+
}
|
|
13
|
+
/** Load every session's newest cut, leaving one fixed file per session.
|
|
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.
|
|
19
|
+
* @param directory - Origin-scoped ledger directory, or undefined when persistence is disabled.
|
|
20
|
+
* @returns The newest saved slice per session identity.
|
|
21
|
+
*/
|
|
22
|
+
export async function loadLedgers(directory) {
|
|
23
|
+
const sessions = new Map();
|
|
24
|
+
if (!directory)
|
|
25
|
+
return sessions;
|
|
26
|
+
await ensureDirectory(directory);
|
|
27
|
+
const superseded = [];
|
|
28
|
+
for (const name of await listEntries(directory)) {
|
|
29
|
+
if (!OWNED_FILE.test(name))
|
|
30
|
+
continue;
|
|
31
|
+
const path = join(directory, name);
|
|
32
|
+
const raw = await readText(path);
|
|
33
|
+
// A file removed between listing and reading is simply absent.
|
|
34
|
+
if (raw === undefined)
|
|
35
|
+
continue;
|
|
36
|
+
const saved = parseLedger(raw);
|
|
37
|
+
if (saved === undefined) {
|
|
38
|
+
await removeFile(path);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if ((sessions.get(saved.sessionId)?.cut ?? -2) <= saved.cut)
|
|
42
|
+
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
|
+
}
|
|
50
|
+
return sessions;
|
|
51
|
+
}
|
|
52
|
+
/** Write one session cut unless the directory already holds a newer one.
|
|
53
|
+
*
|
|
54
|
+
* 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.
|
|
56
|
+
* @param directory - Origin-scoped ledger directory.
|
|
57
|
+
* @param saved - Complete slice to persist.
|
|
58
|
+
* @returns Whether the directory now holds this slice.
|
|
59
|
+
*/
|
|
60
|
+
export async function saveLedger(directory, saved) {
|
|
61
|
+
const path = join(directory, ledgerName(saved.sessionId));
|
|
62
|
+
const existing = await readText(path);
|
|
63
|
+
if (existing !== undefined && persistedCut(existing) > saved.cut)
|
|
64
|
+
return false;
|
|
65
|
+
await writePrivateFile(path, JSON.stringify(saved) + '\n');
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
/** The cut already persisted in one file, or -1 when it holds no decision worth keeping.
|
|
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. */
|
|
82
|
+
function parseLedger(raw) {
|
|
83
|
+
let value;
|
|
84
|
+
try {
|
|
85
|
+
value = JSON.parse(raw);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
91
|
+
return undefined;
|
|
92
|
+
const record = value;
|
|
93
|
+
if (record.version !== LEDGER_VERSION)
|
|
94
|
+
return undefined;
|
|
95
|
+
if (typeof record.sessionId !== 'string' || !Number.isSafeInteger(record.cut) || !Array.isArray(record.charges)) {
|
|
96
|
+
throw new Error('Invalid cost ledger');
|
|
97
|
+
}
|
|
98
|
+
const charges = record.charges;
|
|
99
|
+
if (!charges.every(validCharge))
|
|
100
|
+
throw new Error('Invalid cost ledger');
|
|
101
|
+
return { version: LEDGER_VERSION, sessionId: record.sessionId, cut: record.cut, charges: charges };
|
|
102
|
+
}
|
|
103
|
+
/** One charge is valid when every recorded field is present with the type the fold writes. */
|
|
104
|
+
function validCharge(value) {
|
|
105
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
106
|
+
return false;
|
|
107
|
+
const c = value;
|
|
108
|
+
if (typeof c.key !== 'string' || typeof c.provider !== 'string' || typeof c.model !== 'string')
|
|
109
|
+
return false;
|
|
110
|
+
if (c.priceId !== undefined && typeof c.priceId !== 'string')
|
|
111
|
+
return false;
|
|
112
|
+
if (c.reason !== undefined && typeof c.reason !== 'string')
|
|
113
|
+
return false;
|
|
114
|
+
if (c.amount !== undefined && (typeof c.amount !== 'number' || !Number.isFinite(c.amount) || c.amount < 0))
|
|
115
|
+
return false;
|
|
116
|
+
if (c.estimated !== undefined && c.estimated !== true)
|
|
117
|
+
return false;
|
|
118
|
+
if (c.time !== undefined && (typeof c.time !== 'number' || !Number.isFinite(c.time) || c.time < 0 || c.time > 8.64e15))
|
|
119
|
+
return false;
|
|
120
|
+
if (c.usage !== undefined) {
|
|
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;
|
|
128
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Immutable CNY ledger: a charge is priced once and never follows later price configuration. */
|
|
2
|
+
import type { ObjectValue } from '../transport/wire.ts';
|
|
3
|
+
import { type CostTotal, type Coverage, type PriceVersion } from './types.ts';
|
|
4
|
+
/** Per-origin cache of decided request charges; each scan replaces a session at a fixed cut. */
|
|
5
|
+
export declare class CostLedger {
|
|
6
|
+
readonly prices: PriceVersion[];
|
|
7
|
+
readonly directory?: string | undefined;
|
|
8
|
+
private sessions;
|
|
9
|
+
private totals;
|
|
10
|
+
scannedAt?: number;
|
|
11
|
+
scanning: boolean;
|
|
12
|
+
error: string;
|
|
13
|
+
/** Work the last completed scan performed, so a memory sample can attribute its allocation. */
|
|
14
|
+
lastScan?: {
|
|
15
|
+
sessions: number;
|
|
16
|
+
pages: number;
|
|
17
|
+
events: number;
|
|
18
|
+
};
|
|
19
|
+
constructor(prices?: PriceVersion[], directory?: string | undefined);
|
|
20
|
+
/** Cached charges count as complete; only a failed scan or an empty ledger is partial.
|
|
21
|
+
* @returns Coverage of the current totals, so callers can mark them without re-deriving the rule.
|
|
22
|
+
*/
|
|
23
|
+
get coverage(): Coverage;
|
|
24
|
+
/** Load the newest complete cut per session; recorded amounts load without re-pricing. */
|
|
25
|
+
load(): Promise<void>;
|
|
26
|
+
/** Replace one session using all billing events through the opening snapshot cut.
|
|
27
|
+
*
|
|
28
|
+
* Sampling is replayed, but a charge that an earlier scan already decided keeps its recorded
|
|
29
|
+
* `priceId` and `amount`, so editing `prices.json` only affects requests decided afterwards.
|
|
30
|
+
* @param sessionId - Host session identity.
|
|
31
|
+
* @param cut - Opening cursor, preventing a stale scan from overwriting a newer scan.
|
|
32
|
+
* @param events - Minimal events returned by `costRecords`, across all history pages.
|
|
33
|
+
*/
|
|
34
|
+
replace(sessionId: string, cut: number, events: ObjectValue[]): Promise<void>;
|
|
35
|
+
/** Count the retained ledger so a memory sample can separate it from the transcript window.
|
|
36
|
+
* @returns Sessions and charges currently held, and how many charges carry no amount.
|
|
37
|
+
*/
|
|
38
|
+
summary(): {
|
|
39
|
+
sessions: number;
|
|
40
|
+
charges: number;
|
|
41
|
+
unpriced: number;
|
|
42
|
+
};
|
|
43
|
+
/** Whether this session has a complete cached scan.
|
|
44
|
+
* @param sessionId - Selected session identity.
|
|
45
|
+
* @returns True when a complete scan is available.
|
|
46
|
+
*/
|
|
47
|
+
hasSession(sessionId?: string): boolean;
|
|
48
|
+
/** Describe unpriced model/usage combinations without exposing conversation content.
|
|
49
|
+
* @returns Unique reasons across cached sessions.
|
|
50
|
+
*/
|
|
51
|
+
missing(): string[];
|
|
52
|
+
/** Summarize recorded requests across one session or Beijing calendar days.
|
|
53
|
+
* @param sessionId - Optional session restriction.
|
|
54
|
+
* @param days - Today or today plus the preceding two calendar days.
|
|
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.
|
|
58
|
+
*/
|
|
59
|
+
total(sessionId?: string, days?: 1 | 3, now?: number): CostTotal;
|
|
60
|
+
}
|
|
61
|
+
/** Compact estimates retain an asterisk whenever a subtotal is not exact.
|
|
62
|
+
* @param total - Summary from the ledger.
|
|
63
|
+
* @returns Yuan amount and incompleteness marker.
|
|
64
|
+
*/
|
|
65
|
+
export declare function costText(total: CostTotal): string;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { chargeFor, costDay, DEFAULT_PRICES } from "./pricing.js";
|
|
2
|
+
import { foldSamples } from "./records.js";
|
|
3
|
+
import { loadLedgers, saveLedger } from "./ledger-files.js";
|
|
4
|
+
import { MISSING_USAGE } from "./types.js";
|
|
5
|
+
/** Whether a charge already carries a decision that a later scan must keep. */
|
|
6
|
+
function sealed(charge) {
|
|
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. */
|
|
20
|
+
export class CostLedger {
|
|
21
|
+
prices;
|
|
22
|
+
directory;
|
|
23
|
+
sessions = new Map();
|
|
24
|
+
totals = new Map();
|
|
25
|
+
scannedAt;
|
|
26
|
+
scanning = false;
|
|
27
|
+
error = '';
|
|
28
|
+
/** Work the last completed scan performed, so a memory sample can attribute its allocation. */
|
|
29
|
+
lastScan;
|
|
30
|
+
constructor(prices = DEFAULT_PRICES, directory) {
|
|
31
|
+
this.prices = prices;
|
|
32
|
+
this.directory = directory;
|
|
33
|
+
}
|
|
34
|
+
/** Cached charges count as complete; only a failed scan or an empty ledger is partial.
|
|
35
|
+
* @returns Coverage of the current totals, so callers can mark them without re-deriving the rule.
|
|
36
|
+
*/
|
|
37
|
+
get coverage() {
|
|
38
|
+
if (this.scanning)
|
|
39
|
+
return 'scanning';
|
|
40
|
+
if (this.error)
|
|
41
|
+
return 'partial';
|
|
42
|
+
return this.scannedAt !== undefined || this.sessions.size > 0 ? 'complete' : 'partial';
|
|
43
|
+
}
|
|
44
|
+
/** Load the newest complete cut per session; recorded amounts load without re-pricing. */
|
|
45
|
+
async load() {
|
|
46
|
+
this.totals.clear();
|
|
47
|
+
for (const [sessionId, saved] of await loadLedgers(this.directory)) {
|
|
48
|
+
if ((this.sessions.get(sessionId)?.cut ?? -2) <= saved.cut)
|
|
49
|
+
this.sessions.set(sessionId, saved);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Replace one session using all billing events through the opening snapshot cut.
|
|
53
|
+
*
|
|
54
|
+
* Sampling is replayed, but a charge that an earlier scan already decided keeps its recorded
|
|
55
|
+
* `priceId` and `amount`, so editing `prices.json` only affects requests decided afterwards.
|
|
56
|
+
* @param sessionId - Host session identity.
|
|
57
|
+
* @param cut - Opening cursor, preventing a stale scan from overwriting a newer scan.
|
|
58
|
+
* @param events - Minimal events returned by `costRecords`, across all history pages.
|
|
59
|
+
*/
|
|
60
|
+
async replace(sessionId, cut, events) {
|
|
61
|
+
const current = this.sessions.get(sessionId);
|
|
62
|
+
if ((current?.cut ?? -2) > cut)
|
|
63
|
+
return;
|
|
64
|
+
const previous = new Map((current?.charges ?? []).map(charge => [charge.key, charge]));
|
|
65
|
+
const charges = foldSamples(events).map(sample => decide(this.prices, sample, previous.get(sample.key)));
|
|
66
|
+
const saved = { version: 2, sessionId, cut, charges };
|
|
67
|
+
// Another process may have persisted a newer cut of this session since it was last read.
|
|
68
|
+
if (this.directory && !await saveLedger(this.directory, saved))
|
|
69
|
+
return;
|
|
70
|
+
this.sessions.set(sessionId, saved);
|
|
71
|
+
this.totals.clear();
|
|
72
|
+
}
|
|
73
|
+
/** Count the retained ledger so a memory sample can separate it from the transcript window.
|
|
74
|
+
* @returns Sessions and charges currently held, and how many charges carry no amount.
|
|
75
|
+
*/
|
|
76
|
+
summary() {
|
|
77
|
+
let charges = 0, unpriced = 0;
|
|
78
|
+
for (const session of this.sessions.values()) {
|
|
79
|
+
charges += session.charges.length;
|
|
80
|
+
unpriced += session.charges.filter(charge => charge.amount === undefined).length;
|
|
81
|
+
}
|
|
82
|
+
return { sessions: this.sessions.size, charges, unpriced };
|
|
83
|
+
}
|
|
84
|
+
/** Whether this session has a complete cached scan.
|
|
85
|
+
* @param sessionId - Selected session identity.
|
|
86
|
+
* @returns True when a complete scan is available.
|
|
87
|
+
*/
|
|
88
|
+
hasSession(sessionId) { return sessionId !== undefined && this.sessions.has(sessionId); }
|
|
89
|
+
/** Describe unpriced model/usage combinations without exposing conversation content.
|
|
90
|
+
* @returns Unique reasons across cached sessions.
|
|
91
|
+
*/
|
|
92
|
+
missing() {
|
|
93
|
+
return [...new Set([...this.sessions.values()].flatMap(s => s.charges.filter(c => c.amount === undefined).map(c => `${c.provider}/${c.model}: ${c.reason}`)))];
|
|
94
|
+
}
|
|
95
|
+
/** Summarize recorded requests across one session or Beijing calendar days.
|
|
96
|
+
* @param sessionId - Optional session restriction.
|
|
97
|
+
* @param days - Today or today plus the preceding two calendar days.
|
|
98
|
+
* @param now - Clock used for date attribution.
|
|
99
|
+
* @returns Known subtotal, unpriceable count, and estimated count; an estimated record always
|
|
100
|
+
* names an amount, but a dated range only adds the records it can place inside that range.
|
|
101
|
+
*/
|
|
102
|
+
total(sessionId, days, now = Date.now()) {
|
|
103
|
+
const cacheKey = JSON.stringify([sessionId, days, days ? costDay(now) : '']);
|
|
104
|
+
const cached = this.totals.get(cacheKey);
|
|
105
|
+
if (cached)
|
|
106
|
+
return cached;
|
|
107
|
+
const result = { amount: 0, unknown: 0, estimated: 0, records: 0 };
|
|
108
|
+
const end = costDay(now);
|
|
109
|
+
const start = costDay(now - ((days ?? 1) - 1) * 86400_000);
|
|
110
|
+
for (const session of this.sessions.values()) {
|
|
111
|
+
if (sessionId !== undefined && session.sessionId !== sessionId)
|
|
112
|
+
continue;
|
|
113
|
+
for (const charge of session.charges) {
|
|
114
|
+
if (days && charge.time !== undefined && (costDay(charge.time) < start || costDay(charge.time) > end))
|
|
115
|
+
continue;
|
|
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
|
+
}
|
|
127
|
+
}
|
|
128
|
+
this.totals.set(cacheKey, result);
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Compact estimates retain an asterisk whenever a subtotal is not exact.
|
|
133
|
+
* @param total - Summary from the ledger.
|
|
134
|
+
* @returns Yuan amount and incompleteness marker.
|
|
135
|
+
*/
|
|
136
|
+
export function costText(total) { return `~¥${total.amount.toFixed(4)}${total.unknown || total.estimated ? '*' : ''}`; }
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type PriceDecision, type PriceVersion, type Rates, type Usage } from './types.ts';
|
|
2
|
+
export declare const DEFAULT_PRICES: PriceVersion[];
|
|
3
|
+
/** Validate user-maintained price versions, rejecting ambiguous overlapping intervals.
|
|
4
|
+
* @param value - Parsed prices.json array.
|
|
5
|
+
* @returns Price versions with validated rates and schedules.
|
|
6
|
+
*/
|
|
7
|
+
export declare function pricesFrom(value: unknown): PriceVersion[];
|
|
8
|
+
/** Return the calendar date used by both daily and three-calendar-day summaries.
|
|
9
|
+
* @param time - Epoch milliseconds.
|
|
10
|
+
* @returns Beijing calendar date, YYYY-MM-DD.
|
|
11
|
+
*/
|
|
12
|
+
export declare function costDay(time: number): string;
|
|
13
|
+
/** Select a price by event time, applying half-open local peak windows.
|
|
14
|
+
* @param prices - Validated versions.
|
|
15
|
+
* @param provider - Provider identity from the recorded request.
|
|
16
|
+
* @param model - Recorded model name; official DeepSeek aliases fall back to Pro when containing pro, otherwise Flash.
|
|
17
|
+
* @param time - Recorded settlement timestamp used as a billing-time estimate.
|
|
18
|
+
* @returns Matching price version and per-million-token rates, if known.
|
|
19
|
+
*/
|
|
20
|
+
export declare function priceAt(prices: PriceVersion[], provider: string, model: string, time: number): {
|
|
21
|
+
price: PriceVersion;
|
|
22
|
+
rates: Rates;
|
|
23
|
+
} | undefined;
|
|
24
|
+
/** Select a rate without a settlement time, so an unattributable request still enters the total.
|
|
25
|
+
* The cheapest candidate off-peak rate is a floor: it never overstates, and the charge stays
|
|
26
|
+
* marked as estimated.
|
|
27
|
+
* @param prices - Validated versions.
|
|
28
|
+
* @param provider - Provider identity from the recorded request.
|
|
29
|
+
* @param model - Recorded model name.
|
|
30
|
+
* @returns The candidate version with the lowest off-peak input rate and its rates, if any.
|
|
31
|
+
*/
|
|
32
|
+
export declare function lowestPrice(prices: PriceVersion[], provider: string, model: string): {
|
|
33
|
+
price: PriceVersion;
|
|
34
|
+
rates: Rates;
|
|
35
|
+
} | undefined;
|
|
36
|
+
/** Decide the amount for one request sample using the table loaded at decision time.
|
|
37
|
+
*
|
|
38
|
+
* The returned decision is recorded once and never revisited: a later `prices.json` change must
|
|
39
|
+
* not move a historical amount. Only a sample with no usable usage (`missing usage`) is left
|
|
40
|
+
* undecided, because its request has not finished reporting tokens yet.
|
|
41
|
+
* @param prices - Validated versions currently loaded.
|
|
42
|
+
* @param provider - Provider identity from the recorded request.
|
|
43
|
+
* @param model - Recorded model name.
|
|
44
|
+
* @param time - Recorded settlement timestamp, when the host logged one.
|
|
45
|
+
* @param usage - Disjoint token buckets, when the host reported valid counts.
|
|
46
|
+
* @returns The selected price identity, the amount, and the reason when no amount exists.
|
|
47
|
+
*/
|
|
48
|
+
export declare function chargeFor(prices: PriceVersion[], provider: string, model: string, time: number | undefined, usage: Usage | undefined): PriceDecision;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/** Versioned CNY price tables and the price decision taken for one request sample. */
|
|
2
|
+
import { object } from "../transport/wire.js";
|
|
3
|
+
import { MISSING_USAGE } from "./types.js";
|
|
4
|
+
const clocks = new Map();
|
|
5
|
+
/** Published rates verified on 2026-09-10; preceding dates require historical configuration.
|
|
6
|
+
* Flash and Pro are priced independently, and a separate cache write uses the cache-miss input rate.
|
|
7
|
+
*/
|
|
8
|
+
const OFFICIAL_PRICING = 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing/';
|
|
9
|
+
const PEAK_SCHEDULE = { weekdays: [1, 2, 3, 4, 5], windows: [[540, 720], [840, 1080]] };
|
|
10
|
+
const FLASH_RATES = { peak: { input: 2, cacheRead: 0.04, cacheWrite: 2, output: 8 },
|
|
11
|
+
offPeak: { input: 1, cacheRead: 0.02, cacheWrite: 1, output: 4 } };
|
|
12
|
+
const PRO_RATES = { peak: { input: 9, cacheRead: 0.3, cacheWrite: 9, output: 27 },
|
|
13
|
+
offPeak: { input: 4.5, cacheRead: 0.15, cacheWrite: 4.5, output: 13.5 } };
|
|
14
|
+
export const DEFAULT_PRICES = [
|
|
15
|
+
{ id: 'deepseek-2026-09-10-flash', provider: 'deepseek-official', model: 'deepseek-flash',
|
|
16
|
+
from: '2026-09-10T00:00:00+08:00', currency: 'CNY', source: OFFICIAL_PRICING, timezone: 'Asia/Shanghai',
|
|
17
|
+
...PEAK_SCHEDULE, ...FLASH_RATES },
|
|
18
|
+
{ id: 'deepseek-2026-09-10-pro', provider: 'deepseek-official', model: 'deepseek-v4-pro',
|
|
19
|
+
from: '2026-09-10T00:00:00+08:00', until: '2026-09-14T12:00:00+08:00', currency: 'CNY',
|
|
20
|
+
source: OFFICIAL_PRICING, timezone: 'Asia/Shanghai', ...PEAK_SCHEDULE, ...PRO_RATES },
|
|
21
|
+
// The provider bills `deepseek-v4-pro` requests at Flash rates once V4 Pro is retired.
|
|
22
|
+
{ id: 'deepseek-2026-09-14-pro-served-by-flash', provider: 'deepseek-official', model: 'deepseek-v4-pro',
|
|
23
|
+
from: '2026-09-14T12:00:00+08:00', currency: 'CNY', source: OFFICIAL_PRICING, timezone: 'Asia/Shanghai',
|
|
24
|
+
...PEAK_SCHEDULE, ...FLASH_RATES },
|
|
25
|
+
];
|
|
26
|
+
/** Validate user-maintained price versions, rejecting ambiguous overlapping intervals.
|
|
27
|
+
* @param value - Parsed prices.json array.
|
|
28
|
+
* @returns Price versions with validated rates and schedules.
|
|
29
|
+
*/
|
|
30
|
+
export function pricesFrom(value) {
|
|
31
|
+
if (!Array.isArray(value))
|
|
32
|
+
throw new Error('prices.json must contain an array');
|
|
33
|
+
const ids = new Set();
|
|
34
|
+
for (const raw of value) {
|
|
35
|
+
const p = object(raw);
|
|
36
|
+
for (const key of ['id', 'provider', 'model', 'source', 'timezone', 'from'])
|
|
37
|
+
if (typeof p[key] !== 'string' || !p[key])
|
|
38
|
+
throw new Error(`Invalid price ${key}`);
|
|
39
|
+
if (ids.has(String(p.id)))
|
|
40
|
+
throw new Error('Duplicate price id');
|
|
41
|
+
ids.add(String(p.id));
|
|
42
|
+
const from = Date.parse(String(p.from));
|
|
43
|
+
const until = p.until === undefined ? Infinity : Date.parse(String(p.until));
|
|
44
|
+
if (!Number.isFinite(from) || !(until > from) || p.currency !== 'CNY')
|
|
45
|
+
throw new Error('Invalid price interval or currency');
|
|
46
|
+
new Intl.DateTimeFormat('en', { timeZone: String(p.timezone) }).format();
|
|
47
|
+
for (const key of ['peak', 'offPeak'])
|
|
48
|
+
for (const bucket of ['input', 'cacheRead', 'cacheWrite', 'output']) {
|
|
49
|
+
const rate = object(p[key])[bucket];
|
|
50
|
+
if (typeof rate !== 'number' || !Number.isFinite(rate) || rate < 0)
|
|
51
|
+
throw new Error('Invalid token rate');
|
|
52
|
+
}
|
|
53
|
+
if (!Array.isArray(p.weekdays) || p.weekdays.some(d => typeof d !== 'number' || !Number.isInteger(d) || d < 0 || d > 6)
|
|
54
|
+
|| !Array.isArray(p.windows) || p.windows.some(w => !Array.isArray(w) || w.length !== 2 || w.some(n => typeof n !== 'number' || !Number.isInteger(n)) || Number(w[0]) < 0 || Number(w[1]) > 1440 || Number(w[0]) >= Number(w[1])))
|
|
55
|
+
throw new Error('Invalid peak schedule');
|
|
56
|
+
}
|
|
57
|
+
const prices = value;
|
|
58
|
+
for (const [index, p] of prices.entries())
|
|
59
|
+
for (const q of prices.slice(index + 1)) {
|
|
60
|
+
if (p.provider === q.provider && p.model === q.model && Date.parse(p.from) < (q.until ? Date.parse(q.until) : Infinity)
|
|
61
|
+
&& Date.parse(q.from) < (p.until ? Date.parse(p.until) : Infinity))
|
|
62
|
+
throw new Error('Overlapping price intervals');
|
|
63
|
+
}
|
|
64
|
+
return prices;
|
|
65
|
+
}
|
|
66
|
+
/** Return the calendar date used by both daily and three-calendar-day summaries.
|
|
67
|
+
* @param time - Epoch milliseconds.
|
|
68
|
+
* @returns Beijing calendar date, YYYY-MM-DD.
|
|
69
|
+
*/
|
|
70
|
+
export function costDay(time) { return new Date(time + 8 * 3600_000).toISOString().slice(0, 10); }
|
|
71
|
+
/** Price family used when a recorded model name has no exact entry. */
|
|
72
|
+
function priceFamily(model) { return model.toLowerCase().includes('pro') ? 'deepseek-v4-pro' : 'deepseek-flash'; }
|
|
73
|
+
/** Candidate versions for one request: its exact model first, then the official model family. */
|
|
74
|
+
function candidates(prices, provider, model) {
|
|
75
|
+
const exact = prices.filter(p => p.provider === provider && p.model === model);
|
|
76
|
+
if (exact.length)
|
|
77
|
+
return exact;
|
|
78
|
+
return provider === 'deepseek-official' ? prices.filter(p => p.model === priceFamily(model)) : [];
|
|
79
|
+
}
|
|
80
|
+
/** Select a price by event time, applying half-open local peak windows.
|
|
81
|
+
* @param prices - Validated versions.
|
|
82
|
+
* @param provider - Provider identity from the recorded request.
|
|
83
|
+
* @param model - Recorded model name; official DeepSeek aliases fall back to Pro when containing pro, otherwise Flash.
|
|
84
|
+
* @param time - Recorded settlement timestamp used as a billing-time estimate.
|
|
85
|
+
* @returns Matching price version and per-million-token rates, if known.
|
|
86
|
+
*/
|
|
87
|
+
export function priceAt(prices, provider, model, time) {
|
|
88
|
+
const price = candidates(prices, provider, model).find(p => Date.parse(p.from) <= time && (p.until === undefined || time < Date.parse(p.until)));
|
|
89
|
+
if (!price)
|
|
90
|
+
return;
|
|
91
|
+
let clock = clocks.get(price.timezone);
|
|
92
|
+
if (!clock) {
|
|
93
|
+
clock = new Intl.DateTimeFormat('en-US', { timeZone: price.timezone, weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
|
|
94
|
+
clocks.set(price.timezone, clock);
|
|
95
|
+
}
|
|
96
|
+
const parts = clock.formatToParts(time);
|
|
97
|
+
const part = (name) => parts.find(p => p.type === name).value;
|
|
98
|
+
const day = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(part('weekday'));
|
|
99
|
+
const minute = Number(part('hour')) * 60 + Number(part('minute'));
|
|
100
|
+
return { price, rates: price.weekdays.includes(day) && price.windows.some(([a, b]) => minute >= a && minute < b) ? price.peak : price.offPeak };
|
|
101
|
+
}
|
|
102
|
+
/** Select a rate without a settlement time, so an unattributable request still enters the total.
|
|
103
|
+
* The cheapest candidate off-peak rate is a floor: it never overstates, and the charge stays
|
|
104
|
+
* marked as estimated.
|
|
105
|
+
* @param prices - Validated versions.
|
|
106
|
+
* @param provider - Provider identity from the recorded request.
|
|
107
|
+
* @param model - Recorded model name.
|
|
108
|
+
* @returns The candidate version with the lowest off-peak input rate and its rates, if any.
|
|
109
|
+
*/
|
|
110
|
+
export function lowestPrice(prices, provider, model) {
|
|
111
|
+
let best;
|
|
112
|
+
for (const price of candidates(prices, provider, model)) {
|
|
113
|
+
if (best === undefined || price.offPeak.input < best.rates.input)
|
|
114
|
+
best = { price, rates: price.offPeak };
|
|
115
|
+
}
|
|
116
|
+
return best;
|
|
117
|
+
}
|
|
118
|
+
/** Decide the amount for one request sample using the table loaded at decision time.
|
|
119
|
+
*
|
|
120
|
+
* The returned decision is recorded once and never revisited: a later `prices.json` change must
|
|
121
|
+
* not move a historical amount. Only a sample with no usable usage (`missing usage`) is left
|
|
122
|
+
* undecided, because its request has not finished reporting tokens yet.
|
|
123
|
+
* @param prices - Validated versions currently loaded.
|
|
124
|
+
* @param provider - Provider identity from the recorded request.
|
|
125
|
+
* @param model - Recorded model name.
|
|
126
|
+
* @param time - Recorded settlement timestamp, when the host logged one.
|
|
127
|
+
* @param usage - Disjoint token buckets, when the host reported valid counts.
|
|
128
|
+
* @returns The selected price identity, the amount, and the reason when no amount exists.
|
|
129
|
+
*/
|
|
130
|
+
export function chargeFor(prices, provider, model, time, usage) {
|
|
131
|
+
if (!usage)
|
|
132
|
+
return { reason: MISSING_USAGE };
|
|
133
|
+
const selected = time === undefined ? lowestPrice(prices, provider, model) : priceAt(prices, provider, model, time);
|
|
134
|
+
if (!selected)
|
|
135
|
+
return { reason: 'no price version' };
|
|
136
|
+
const amount = (usage.input * selected.rates.input + usage.output * selected.rates.output
|
|
137
|
+
+ usage.cacheRead * selected.rates.cacheRead + usage.cacheWrite * selected.rates.cacheWrite) / 1e6;
|
|
138
|
+
if (!Number.isFinite(amount))
|
|
139
|
+
return { reason: 'invalid estimate' };
|
|
140
|
+
return { priceId: selected.price.id, amount, ...(time === undefined ? { estimated: true } : {}) };
|
|
141
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Fold host history records into per-request samples; conversation text never enters the ledger. */
|
|
2
|
+
import { type ObjectValue } from '../transport/wire.ts';
|
|
3
|
+
import type { ChargeSample } from './types.ts';
|
|
4
|
+
/** Keep only billing-relevant fields; prompts, tool bodies, cookies and keys never enter the ledger.
|
|
5
|
+
* @param records - One HTTP history page's records.
|
|
6
|
+
* @returns Minimal durable events for a deterministic usage fold.
|
|
7
|
+
*/
|
|
8
|
+
export declare function costRecords(records: unknown): ObjectValue[];
|
|
9
|
+
/** Fold minimal billing events into one sample per model attempt.
|
|
10
|
+
*
|
|
11
|
+
* A replacement sample in the same turn and step updates its attempt's sample, a retry starts a
|
|
12
|
+
* new one, and fork-inherited records are excluded. The sample carries no amount: deciding one
|
|
13
|
+
* belongs to the ledger, which records the decision once.
|
|
14
|
+
* @param events - Minimal events returned by `costRecords`, across all history pages.
|
|
15
|
+
* @returns Ordered samples with the last valid usage observed for each attempt.
|
|
16
|
+
*/
|
|
17
|
+
export declare function foldSamples(events: readonly ObjectValue[]): ChargeSample[];
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Fold host history records into per-request samples; conversation text never enters the ledger. */
|
|
2
|
+
import { array, object } from "../transport/wire.js";
|
|
3
|
+
/** Host event types that carry billing-relevant usage or route context. */
|
|
4
|
+
const BILLING_EVENTS = new Set(['request/context', 'assistant/message', 'assistant/attempt', 'llm/retry-started', 'session/end-seed']);
|
|
5
|
+
/** Keep only billing-relevant fields; prompts, tool bodies, cookies and keys never enter the ledger.
|
|
6
|
+
* @param records - One HTTP history page's records.
|
|
7
|
+
* @returns Minimal durable events for a deterministic usage fold.
|
|
8
|
+
*/
|
|
9
|
+
export function costRecords(records) {
|
|
10
|
+
return array(records).map(raw => object(object(raw).event)).filter(e => BILLING_EVENTS.has(String(e.type))).map(e => {
|
|
11
|
+
const d = object(e.data);
|
|
12
|
+
const m = object(d.message ?? {});
|
|
13
|
+
const stream = array(d.stream ?? []).map(r => object(object(r).chunk ?? {})).filter(c => c.type === 'usage');
|
|
14
|
+
return { seq: e.seq ?? null, time: e.time ?? null, type: e.type, data: {
|
|
15
|
+
inherited: d.inherited ?? false, turn: d.turn ?? null, step: d.step ?? null, provider: d.provider ?? null, model: d.model ?? null,
|
|
16
|
+
source: m.source ?? null, usage: d.usage ?? stream.at(-1)?.usage ?? null,
|
|
17
|
+
} };
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/** Fold minimal billing events into one sample per model attempt.
|
|
21
|
+
*
|
|
22
|
+
* A replacement sample in the same turn and step updates its attempt's sample, a retry starts a
|
|
23
|
+
* new one, and fork-inherited records are excluded. The sample carries no amount: deciding one
|
|
24
|
+
* belongs to the ledger, which records the decision once.
|
|
25
|
+
* @param events - Minimal events returned by `costRecords`, across all history pages.
|
|
26
|
+
* @returns Ordered samples with the last valid usage observed for each attempt.
|
|
27
|
+
*/
|
|
28
|
+
export function foldSamples(events) {
|
|
29
|
+
const inheritedCut = Math.max(-1, ...events.filter(e => e.type === 'session/end-seed' && object(e.data).inherited === true).map(e => Number(e.seq)));
|
|
30
|
+
const samples = [];
|
|
31
|
+
let route = {};
|
|
32
|
+
let last;
|
|
33
|
+
for (const e of [...new Map(events.map(e => [Number(e.seq), e])).values()].sort((a, b) => Number(a.seq) - Number(b.seq))) {
|
|
34
|
+
const d = object(e.data);
|
|
35
|
+
if (e.type === 'request/context') {
|
|
36
|
+
route = d;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (Number(e.seq) <= inheritedCut || e.type === 'session/end-seed')
|
|
40
|
+
continue;
|
|
41
|
+
if (e.type === 'llm/retry-started') {
|
|
42
|
+
if (last?.turn === d.turn && last?.step === d.step)
|
|
43
|
+
last = undefined;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const source = object(d.source ?? {});
|
|
47
|
+
const provider = String(source.provider ?? route.provider ?? '');
|
|
48
|
+
const model = String(source.model ?? route.model ?? '');
|
|
49
|
+
const time = typeof e.time === 'number' && Number.isFinite(e.time) && e.time >= 0 && e.time <= 8.64e15 ? e.time : undefined;
|
|
50
|
+
const usage = validUsage(d.usage);
|
|
51
|
+
const index = last && d.turn !== null && d.step !== null && last.turn === d.turn && last.step === d.step ? last.index : samples.length;
|
|
52
|
+
if (!usage && samples[index]?.usage)
|
|
53
|
+
continue;
|
|
54
|
+
samples[index] = { key: samples[index]?.key ?? String(e.seq), ...(time === undefined ? {} : { time }), provider, model, ...(usage ? { usage } : {}) };
|
|
55
|
+
last = { turn: d.turn, step: d.step, index };
|
|
56
|
+
}
|
|
57
|
+
return samples;
|
|
58
|
+
}
|
|
59
|
+
/** Accept a token report only when every bucket is a non-negative integer and totals agree. */
|
|
60
|
+
function validUsage(value) {
|
|
61
|
+
const u = object(value ?? {});
|
|
62
|
+
const buckets = [u.inputTokens, u.outputTokens, u.cacheReadTokens ?? 0, u.cacheWriteTokens ?? 0];
|
|
63
|
+
const valid = buckets.every(n => typeof n === 'number' && Number.isSafeInteger(n) && n >= 0)
|
|
64
|
+
&& (u.totalTokens === undefined || typeof u.totalTokens === 'number' && Number.isSafeInteger(u.totalTokens) && u.totalTokens === buckets.reduce((sum, n) => sum + Number(n), 0));
|
|
65
|
+
return valid ? { input: Number(buckets[0]), output: Number(buckets[1]), cacheRead: Number(buckets[2]), cacheWrite: Number(buckets[3]) } : undefined;
|
|
66
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Address and page one session's complete billing history over the host connection. */
|
|
2
|
+
import { type Client } from '../transport/client.ts';
|
|
3
|
+
import { type ObjectValue } from '../transport/wire.ts';
|
|
4
|
+
/** Wire addresses for one `session/list` row, in the order the cost scan should try them.
|
|
5
|
+
*
|
|
6
|
+
* A subagent child is reachable only under its durable parent, and the list row omits the delivery
|
|
7
|
+
* mode, so both modes are offered with the continuable form first.
|
|
8
|
+
* @param session - One row from the host session list.
|
|
9
|
+
* @returns One plain-session address, or both subagent forms when the row is a child.
|
|
10
|
+
*/
|
|
11
|
+
export declare function costAddresses(session: ObjectValue): ObjectValue[];
|
|
12
|
+
/** Read one session's complete cost history, retrying a subagent child with its other delivery mode.
|
|
13
|
+
* @param client - Authenticated host transport.
|
|
14
|
+
* @param session - One row from the host session list.
|
|
15
|
+
* @param signal - Cancels paging without cancelling any agent work.
|
|
16
|
+
* @param onPage - Counts each history request, so a scan can report how much it re-read.
|
|
17
|
+
* @returns Opening cursor and the minimal billing events behind it.
|
|
18
|
+
*/
|
|
19
|
+
export declare function sessionCostHistory(client: Client, session: ObjectValue, signal: AbortSignal, onPage?: () => void): Promise<{
|
|
20
|
+
cursor: number;
|
|
21
|
+
events: ObjectValue[];
|
|
22
|
+
}>;
|