@celestea/runtime 2.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/dist/agent-config.d.ts +18 -0
- package/dist/agent-config.js +31 -0
- package/dist/autowake.d.ts +141 -0
- package/dist/autowake.js +262 -0
- package/dist/compact/index.d.ts +13 -0
- package/dist/compact/index.js +13 -0
- package/dist/compact/plan.d.ts +51 -0
- package/dist/compact/plan.js +98 -0
- package/dist/compact/rewrite.d.ts +23 -0
- package/dist/compact/rewrite.js +79 -0
- package/dist/compact/run.d.ts +44 -0
- package/dist/compact/run.js +59 -0
- package/dist/compact/summarize.d.ts +30 -0
- package/dist/compact/summarize.js +70 -0
- package/dist/compact/transcript.d.ts +35 -0
- package/dist/compact/transcript.js +88 -0
- package/dist/compose.d.ts +117 -0
- package/dist/compose.js +191 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.js +34 -0
- package/dist/frames.d.ts +46 -0
- package/dist/frames.js +62 -0
- package/dist/gen.d.ts +86 -0
- package/dist/gen.js +129 -0
- package/dist/host/engine-session.d.ts +117 -0
- package/dist/host/engine-session.js +109 -0
- package/dist/host/index.d.ts +39 -0
- package/dist/host/index.js +39 -0
- package/dist/host/provider-target.d.ts +113 -0
- package/dist/host/provider-target.js +116 -0
- package/dist/inbox-checkpoint.d.ts +18 -0
- package/dist/inbox-checkpoint.js +37 -0
- package/dist/inbox.d.ts +94 -0
- package/dist/inbox.js +139 -0
- package/dist/index.d.ts +71 -0
- package/dist/index.js +71 -0
- package/dist/ledger-io.d.ts +27 -0
- package/dist/ledger-io.js +74 -0
- package/dist/ledger-llm.d.ts +48 -0
- package/dist/ledger-llm.js +115 -0
- package/dist/ledger-query.d.ts +91 -0
- package/dist/ledger-query.js +153 -0
- package/dist/ledger.d.ts +271 -0
- package/dist/ledger.js +444 -0
- package/dist/pricing.d.ts +100 -0
- package/dist/pricing.js +167 -0
- package/dist/profile.d.ts +26 -0
- package/dist/profile.js +39 -0
- package/dist/recovery.d.ts +56 -0
- package/dist/recovery.js +91 -0
- package/dist/retention.d.ts +49 -0
- package/dist/retention.js +119 -0
- package/dist/runtime.d.ts +197 -0
- package/dist/runtime.js +347 -0
- package/dist/sanitize.d.ts +35 -0
- package/dist/sanitize.js +36 -0
- package/dist/session-binding.d.ts +36 -0
- package/dist/session-binding.js +33 -0
- package/dist/session-registry.d.ts +238 -0
- package/dist/session-registry.js +388 -0
- package/dist/status.d.ts +279 -0
- package/dist/status.js +411 -0
- package/dist/tokens.d.ts +25 -0
- package/dist/tokens.js +25 -0
- package/dist/turn-runner.d.ts +169 -0
- package/dist/turn-runner.js +242 -0
- package/dist/usage.d.ts +64 -0
- package/dist/usage.js +88 -0
- package/dist/watchdog-mount.d.ts +79 -0
- package/dist/watchdog-mount.js +120 -0
- package/dist/worker-wiring.d.ts +74 -0
- package/dist/worker-wiring.js +107 -0
- package/package.json +31 -0
package/dist/ledger.js
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cost & usage ledger (iteration E §3 P0, W728) — append-only, step granularity.
|
|
3
|
+
*
|
|
4
|
+
* One JSON line per model step in `<data dir>/usage-ledger.jsonl`, plus one
|
|
5
|
+
* `turn_total` line per turn for reconciliation. The file is the durable,
|
|
6
|
+
* never-rewritten record of what was spent: the in-memory usage tracker is
|
|
7
|
+
* reset by every generation rebuild (§3.1 G3-1), the ledger is not.
|
|
8
|
+
*
|
|
9
|
+
* Discipline (all mechanical, see the §3.4 assertions):
|
|
10
|
+
* - append-only: a line is written once and never edited (no rewrite, no
|
|
11
|
+
* compaction). Rotation (§3.3 P1 ④, W785) rolls the WHOLE file to
|
|
12
|
+
* `<path>.1` once it passes [USAGE_LEDGER_MAX_BYTES]: the current file is
|
|
13
|
+
* still only ever appended to, and `read()` reads the current file only
|
|
14
|
+
* (the CUMULATIVE views call `readAll()`, which prefixes the rolled `.1`);
|
|
15
|
+
* - one `writeSync` on an `O_APPEND` fd per record, mode 0600, so concurrent
|
|
16
|
+
* writers cannot interleave a line;
|
|
17
|
+
* - idempotent: the key is `(session, turn_id, step, attempt)`; a key already
|
|
18
|
+
* booked by THIS writer is skipped, never rewritten. The key set is
|
|
19
|
+
* per-process ON PURPOSE: seeding it from the file would silently drop
|
|
20
|
+
* legitimate out-of-turn rows, whose turn_id is null and whose step index
|
|
21
|
+
* restarts at 1 after a restart (cross-process dedupe is still open work;
|
|
22
|
+
* rotation does not change it, §3.3);
|
|
23
|
+
* - no prompt/message text and no credential can reach a line: a record is
|
|
24
|
+
* built from counters, names and prices only (§3.5 R3-4);
|
|
25
|
+
* - observation only: a write failure is reported on stderr and swallowed —
|
|
26
|
+
* bookkeeping must never change request behaviour (§3.5 R3-3).
|
|
27
|
+
*
|
|
28
|
+
* WHERE a step is observed: `packages/llm`'s `LlmError.httpStatus/retryable`
|
|
29
|
+
* (W723) is only visible at the `Llm` seam, and so are the step boundaries — a
|
|
30
|
+
* stream that tears AFTER a usage frame is ONE error row, not an ok row plus an
|
|
31
|
+
* error row (§3.2.3). [createLedgerLlm] therefore drives `beginStep` → one
|
|
32
|
+
* `record` per usage frame → `close` at the stream's terminal event, while the
|
|
33
|
+
* turn boundaries come from the runtime's [TurnLedgerHooks]. [UsageLedger]
|
|
34
|
+
* still implements the runtime's structural `UsageAccounting` seam, and its
|
|
35
|
+
* `latest`/`total` views are derived from the FILE, so they survive a restart.
|
|
36
|
+
*/
|
|
37
|
+
import { closeSync, openSync, renameSync, statSync, writeSync } from "node:fs";
|
|
38
|
+
import { join } from "node:path";
|
|
39
|
+
import { usageAdd, zeroUsage } from "@celestea/core";
|
|
40
|
+
import { costAdd, costOf, emptyPricing, loadPricingFile, priceFor, priceSnapshot, pricingPath, } from "./pricing.js";
|
|
41
|
+
import { LedgerKeySet, readLedgerRecords } from "./ledger-io.js";
|
|
42
|
+
/** `<data dir>/usage-ledger.jsonl` (§3.2.1). */
|
|
43
|
+
export const USAGE_LEDGER_FILE = "usage-ledger.jsonl";
|
|
44
|
+
/** `CELESTEA_USAGE_LEDGER=off` disables the ledger entirely (§3.5 R3-3). */
|
|
45
|
+
export const ENV_USAGE_LEDGER = "CELESTEA_USAGE_LEDGER";
|
|
46
|
+
/** Path override (`CELESTEA_USAGE_LEDGER_FILE`). */
|
|
47
|
+
export const ENV_USAGE_LEDGER_FILE = "CELESTEA_USAGE_LEDGER_FILE";
|
|
48
|
+
/** Record version, written as `v` on every line. */
|
|
49
|
+
export const LEDGER_VERSION = 1;
|
|
50
|
+
/**
|
|
51
|
+
* Rotation threshold (§3.3 P1 ④): before a write, a ledger file at or above this
|
|
52
|
+
* size is rolled to `<path>.1` (replacing the previous `.1`) and the next write
|
|
53
|
+
* recreates the current file. 16 MiB is the audit-trail discipline borrowed from
|
|
54
|
+
* `grants-audit.jsonl`, never a durability boundary: the rolled file keeps every
|
|
55
|
+
* row it had.
|
|
56
|
+
*/
|
|
57
|
+
export const USAGE_LEDGER_MAX_BYTES = 16 * 1024 * 1024;
|
|
58
|
+
/**
|
|
59
|
+
* Bound of the in-process idempotency-key set (P2-5, W836): a long-lived engine
|
|
60
|
+
* books one key per model step, so the set is trimmed once it exceeds this many
|
|
61
|
+
* entries (oldest first). Per-turn keys are evicted wholesale when their turn
|
|
62
|
+
* ends ([UsageLedgerFile.evictTurn]); this cap is only the backstop for keys
|
|
63
|
+
* whose turn never closed (torn process, out-of-turn row).
|
|
64
|
+
*/
|
|
65
|
+
export const USAGE_LEDGER_MAX_KEYS = 100_000;
|
|
66
|
+
/** The open turn of a session log: the newest `turn_start` with no `turn_end`. */
|
|
67
|
+
export function openTurnOf(events) {
|
|
68
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
69
|
+
const ev = events[i];
|
|
70
|
+
if (ev === undefined)
|
|
71
|
+
continue;
|
|
72
|
+
if (ev.type === "turn_end")
|
|
73
|
+
return null;
|
|
74
|
+
if (ev.type === "turn_start")
|
|
75
|
+
return { id: ev.id, turn: turnNumberOf(ev.id) };
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
/** `turn-7` -> 7 (the ledger only needs the display number of the log's id). */
|
|
80
|
+
export function turnNumberOf(id) {
|
|
81
|
+
const m = /^turn-(\d+)$/.exec(id);
|
|
82
|
+
if (m === null || m[1] === undefined)
|
|
83
|
+
return null;
|
|
84
|
+
const n = Number.parseInt(m[1], 10);
|
|
85
|
+
return Number.isSafeInteger(n) ? n : null;
|
|
86
|
+
}
|
|
87
|
+
/** The idempotency key of a step row (§3.2.3). */
|
|
88
|
+
export function ledgerKey(record) {
|
|
89
|
+
return `${record.session}|${record.turn_id ?? "-"}|${record.step}|${record.attempt}`;
|
|
90
|
+
}
|
|
91
|
+
/** Append-only writer of one ledger file, shared by every session (§3.6). */
|
|
92
|
+
export class UsageLedgerFile {
|
|
93
|
+
fd = null;
|
|
94
|
+
keys = new LedgerKeySet(USAGE_LEDGER_MAX_KEYS);
|
|
95
|
+
target;
|
|
96
|
+
clock;
|
|
97
|
+
maxBytes;
|
|
98
|
+
pricing;
|
|
99
|
+
constructor(opts) {
|
|
100
|
+
this.target = opts.path;
|
|
101
|
+
this.clock = opts.now ?? Date.now;
|
|
102
|
+
this.maxBytes = opts.maxBytes ?? USAGE_LEDGER_MAX_BYTES;
|
|
103
|
+
this.pricing = opts.pricing ?? emptyPricing(null);
|
|
104
|
+
}
|
|
105
|
+
/** Absolute path of the append-only ledger. */
|
|
106
|
+
get path() {
|
|
107
|
+
return this.target;
|
|
108
|
+
}
|
|
109
|
+
/** Second-resolution timestamp of a row (the ledger's own injectable clock). */
|
|
110
|
+
stamp() {
|
|
111
|
+
return Math.floor(this.clock() / 1000);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Append one record. False = the key was already booked, or the write failed
|
|
115
|
+
* (reported on stderr, never thrown: bookkeeping cannot break a turn).
|
|
116
|
+
*/
|
|
117
|
+
append(record, key = null) {
|
|
118
|
+
if (key !== null && this.keys.has(key))
|
|
119
|
+
return false;
|
|
120
|
+
try {
|
|
121
|
+
this.rotateIfLarge();
|
|
122
|
+
writeSync(this.ensureFd(), `${JSON.stringify(record)}\n`);
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
// P1-3 (W836): the key is committed ONLY after the row reached the file.
|
|
126
|
+
// Committing it first poisoned the rest of the turn: the failed step kept
|
|
127
|
+
// its number, recomputed the same key, and every later step was dropped as
|
|
128
|
+
// a duplicate. A failed write must leave no trace beyond the warning.
|
|
129
|
+
warn(`ledger write failed (${errorText(e)})`);
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
if (key !== null)
|
|
133
|
+
this.keys.add(key);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
/** The CURRENT file records only, in file order (audit detail, P2-2). */
|
|
137
|
+
read() {
|
|
138
|
+
return readLedgerRecords(this.target);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The CUMULATIVE records: the rolled `.1` segment first, then the current
|
|
142
|
+
* file, so a rotation does not hide history from total()/latest() or the host
|
|
143
|
+
* cost views (P2-2, W836). `read()` stays current-only so an audit detail view
|
|
144
|
+
* cannot double-count rows that are also reachable under `.1`.
|
|
145
|
+
*/
|
|
146
|
+
readAll() {
|
|
147
|
+
return [...readLedgerRecords(`${this.target}.1`), ...this.read()];
|
|
148
|
+
}
|
|
149
|
+
/** How many idempotency keys are remembered (bounded-memory diagnostics). */
|
|
150
|
+
get keyCount() {
|
|
151
|
+
return this.keys.size;
|
|
152
|
+
}
|
|
153
|
+
/** Drop the keys of ONE closed turn (see [LedgerKeySet.evictTurn]). */
|
|
154
|
+
evictTurn(session, turnId) {
|
|
155
|
+
this.keys.evictTurn(session, turnId);
|
|
156
|
+
}
|
|
157
|
+
/** Close the descriptor (idempotent; the file itself is never truncated). */
|
|
158
|
+
close() {
|
|
159
|
+
if (this.fd === null)
|
|
160
|
+
return;
|
|
161
|
+
try {
|
|
162
|
+
closeSync(this.fd);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// A descriptor we cannot close must not fail a shutdown.
|
|
166
|
+
}
|
|
167
|
+
this.fd = null;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Roll the file to `<path>.1` once it reaches [maxBytes] (§3.3 P1 ④).
|
|
171
|
+
*
|
|
172
|
+
* The fd is closed FIRST and the rename replaces any previous `.1`, so the
|
|
173
|
+
* rolled file is the complete prefix and the next `ensureFd()` starts a fresh
|
|
174
|
+
* current file. A failure is reported on stderr and swallowed (observation
|
|
175
|
+
* discipline, §3.5 R3-3): the worst case is a file that keeps growing.
|
|
176
|
+
*/
|
|
177
|
+
rotateIfLarge() {
|
|
178
|
+
let size = 0;
|
|
179
|
+
try {
|
|
180
|
+
size = statSync(this.target).size;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// No file yet: nothing to roll.
|
|
184
|
+
}
|
|
185
|
+
if (size < this.maxBytes)
|
|
186
|
+
return;
|
|
187
|
+
try {
|
|
188
|
+
this.close();
|
|
189
|
+
renameSync(this.target, `${this.target}.1`);
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
warn(`ledger rotation failed (${errorText(e)})`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** One `O_APPEND` descriptor; one `writeSync` per record keeps lines whole. */
|
|
196
|
+
ensureFd() {
|
|
197
|
+
if (this.fd === null)
|
|
198
|
+
this.fd = openSync(this.target, "a", 0o600);
|
|
199
|
+
return this.fd;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** Per-session ledger: usage frames in, append-only rows out (§3.3 P0 ①–④). */
|
|
203
|
+
export class UsageLedger {
|
|
204
|
+
file;
|
|
205
|
+
session;
|
|
206
|
+
logRef = null;
|
|
207
|
+
acc = null;
|
|
208
|
+
current = null;
|
|
209
|
+
constructor(opts) {
|
|
210
|
+
this.session = opts.session;
|
|
211
|
+
this.file = opts.file;
|
|
212
|
+
}
|
|
213
|
+
/** The process-shared file this session books into. */
|
|
214
|
+
get ledgerFile() {
|
|
215
|
+
return this.file;
|
|
216
|
+
}
|
|
217
|
+
/** Turn start: bind the session log, drop the previous turn's buffer. */
|
|
218
|
+
beginTurn(log) {
|
|
219
|
+
this.logRef = log;
|
|
220
|
+
this.acc = null;
|
|
221
|
+
}
|
|
222
|
+
/** Turn end: flush the summary row (a turn that booked nothing writes none). */
|
|
223
|
+
endTurn(outcome) {
|
|
224
|
+
const acc = this.acc;
|
|
225
|
+
this.acc = null;
|
|
226
|
+
if (acc !== null)
|
|
227
|
+
this.file.evictTurn(this.session, acc.turnId);
|
|
228
|
+
if (acc === null || acc.steps === 0)
|
|
229
|
+
return;
|
|
230
|
+
const complete = acc.unpriced.size === 0 && acc.billedUnknown === 0;
|
|
231
|
+
this.file.append({
|
|
232
|
+
v: LEDGER_VERSION,
|
|
233
|
+
ts: this.file.stamp(),
|
|
234
|
+
kind: "turn_total",
|
|
235
|
+
session: this.session,
|
|
236
|
+
turn: acc.turn,
|
|
237
|
+
turn_id: acc.turnId,
|
|
238
|
+
steps: acc.steps,
|
|
239
|
+
attempts: acc.attempts,
|
|
240
|
+
usage: { ...acc.usage },
|
|
241
|
+
cost: acc.cost === null ? null : { ...acc.cost },
|
|
242
|
+
cost_complete: complete,
|
|
243
|
+
priced_by: complete ? "table" : "unpriced",
|
|
244
|
+
unpriced_models: [...acc.unpriced].sort(),
|
|
245
|
+
billed_unknown_steps: acc.billedUnknown,
|
|
246
|
+
outcome,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Open one model step. The turn identity is captured HERE (not at close), so
|
|
251
|
+
* a step that outlives its turn still books against the turn it belongs to.
|
|
252
|
+
*/
|
|
253
|
+
beginStep(info) {
|
|
254
|
+
const open = this.logRef === null ? null : openTurnOf(this.logRef.events());
|
|
255
|
+
const ref = { info, turnId: open?.id ?? null, turn: open?.turn ?? null };
|
|
256
|
+
const handle = this.stepHandle(ref);
|
|
257
|
+
this.current = handle;
|
|
258
|
+
return handle;
|
|
259
|
+
}
|
|
260
|
+
/** UsageRecorder: one frame for whichever step is open (see [beginStep]). */
|
|
261
|
+
record(usage) {
|
|
262
|
+
this.current?.record(usage);
|
|
263
|
+
}
|
|
264
|
+
/** Usage of the most recent booked step of this session (file-derived). */
|
|
265
|
+
latest() {
|
|
266
|
+
for (const record of this.rows()) {
|
|
267
|
+
if (record.usage !== null)
|
|
268
|
+
return { ...record.usage };
|
|
269
|
+
}
|
|
270
|
+
return zeroUsage();
|
|
271
|
+
}
|
|
272
|
+
/** Every token this session ever booked (survives a restart, §3.4 C5). */
|
|
273
|
+
total() {
|
|
274
|
+
return aggregateUsage(this.rows()).tokens;
|
|
275
|
+
}
|
|
276
|
+
/** C3/C5 view: tokens, cost, and the models the table could not price. */
|
|
277
|
+
totals() {
|
|
278
|
+
return aggregateUsage(this.rows());
|
|
279
|
+
}
|
|
280
|
+
/** This session's step rows, newest first (`turn_total` rows excluded). */
|
|
281
|
+
rows() {
|
|
282
|
+
return this.file
|
|
283
|
+
.readAll()
|
|
284
|
+
.filter((r) => r.kind !== "turn_total" && r.session === this.session)
|
|
285
|
+
.reverse();
|
|
286
|
+
}
|
|
287
|
+
/** One handle per step: its own buffer, closed at most once. */
|
|
288
|
+
stepHandle(ref) {
|
|
289
|
+
let buffered = null;
|
|
290
|
+
let closed = false;
|
|
291
|
+
return {
|
|
292
|
+
record: (usage) => {
|
|
293
|
+
if (!closed)
|
|
294
|
+
buffered = usage;
|
|
295
|
+
},
|
|
296
|
+
close: (outcome) => {
|
|
297
|
+
if (closed)
|
|
298
|
+
return;
|
|
299
|
+
closed = true;
|
|
300
|
+
if (outcome.kind === "ok" && buffered === null)
|
|
301
|
+
return;
|
|
302
|
+
this.book(ref, outcome, buffered);
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
/** Build the row of one closed step and append it (idempotent by key). */
|
|
307
|
+
book(ref, outcome, usage) {
|
|
308
|
+
const acc = this.accumulator(ref.turnId, ref.turn);
|
|
309
|
+
// P1-3 (W836): an INDEPENDENT step number, consumed even when the write
|
|
310
|
+
// fails. Reusing `acc.steps` (which only counts rows that reached the file)
|
|
311
|
+
// would hand the same number to the next step, so a later successful row
|
|
312
|
+
// could collide with a key the failed write had left behind.
|
|
313
|
+
const step = acc.nextStep + 1;
|
|
314
|
+
acc.nextStep = step;
|
|
315
|
+
const price = usage === null ? null : priceFor(this.file.pricing, ref.info.model);
|
|
316
|
+
const cost = usage !== null && price !== null ? costOf(usage, price) : null;
|
|
317
|
+
const record = {
|
|
318
|
+
v: LEDGER_VERSION,
|
|
319
|
+
ts: this.file.stamp(),
|
|
320
|
+
kind: outcome.kind,
|
|
321
|
+
session: this.session,
|
|
322
|
+
turn: ref.turn,
|
|
323
|
+
turn_id: ref.turnId,
|
|
324
|
+
step,
|
|
325
|
+
attempt: ref.info.attempt,
|
|
326
|
+
provider: ref.info.provider,
|
|
327
|
+
model: ref.info.model,
|
|
328
|
+
base_url_host: ref.info.base_url_host,
|
|
329
|
+
usage: usage === null ? null : { ...usage },
|
|
330
|
+
billed_unknown: usage === null,
|
|
331
|
+
error_kind: outcome.error_kind ?? null,
|
|
332
|
+
http_status: outcome.http_status ?? null,
|
|
333
|
+
retryable: outcome.retryable ?? null,
|
|
334
|
+
price: price === null ? null : priceSnapshot(this.file.pricing, price),
|
|
335
|
+
cost,
|
|
336
|
+
priced_by: cost === null ? "unpriced" : "table",
|
|
337
|
+
fallback_from: ref.info.fallback_from,
|
|
338
|
+
};
|
|
339
|
+
if (!this.file.append(record, ledgerKey(record)))
|
|
340
|
+
return;
|
|
341
|
+
acc.steps += 1;
|
|
342
|
+
// E §4/§3 P1 (W785): `attempts` counts ATTEMPTS, not `attempt+1` sums. The
|
|
343
|
+
// P0 form was equivalent while every row was attempt 0; with a real attempt
|
|
344
|
+
// dimension (fallback chain) summing the indices would report 1+2+3 = 6 for
|
|
345
|
+
// three attempts (§3.4 D6). Row counts are unchanged for a P0-shaped turn.
|
|
346
|
+
acc.attempts += 1;
|
|
347
|
+
if (usage !== null)
|
|
348
|
+
acc.usage = usageAdd(acc.usage, usage);
|
|
349
|
+
if (cost !== null)
|
|
350
|
+
acc.cost = acc.cost === null ? cost : costAdd(acc.cost, cost);
|
|
351
|
+
if (usage !== null && cost === null)
|
|
352
|
+
acc.unpriced.add(ref.info.model ?? "(unknown model)");
|
|
353
|
+
if (usage === null)
|
|
354
|
+
acc.billedUnknown += 1;
|
|
355
|
+
}
|
|
356
|
+
/** The accumulator of the step's turn, created on first use. */
|
|
357
|
+
accumulator(turnId, turn) {
|
|
358
|
+
if (this.acc !== null && this.acc.turnId === turnId)
|
|
359
|
+
return this.acc;
|
|
360
|
+
this.acc = {
|
|
361
|
+
turnId,
|
|
362
|
+
turn,
|
|
363
|
+
nextStep: 0,
|
|
364
|
+
steps: 0,
|
|
365
|
+
attempts: 0,
|
|
366
|
+
usage: zeroUsage(),
|
|
367
|
+
cost: null,
|
|
368
|
+
unpriced: new Set(),
|
|
369
|
+
billedUnknown: 0,
|
|
370
|
+
};
|
|
371
|
+
return this.acc;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/** Factory form (`createXxx` convention, ARCHITECTURE.md §6.1). */
|
|
375
|
+
export function createUsageLedger(opts) {
|
|
376
|
+
return new UsageLedger(opts);
|
|
377
|
+
}
|
|
378
|
+
/** Sum the step rows of one session (or of the whole file when omitted). */
|
|
379
|
+
export function aggregateUsage(records, session) {
|
|
380
|
+
const totals = {
|
|
381
|
+
currency: "CNY",
|
|
382
|
+
price_version: null,
|
|
383
|
+
records: 0,
|
|
384
|
+
tokens: zeroUsage(),
|
|
385
|
+
cost: null,
|
|
386
|
+
cost_complete: true,
|
|
387
|
+
unpriced_records: 0,
|
|
388
|
+
billed_unknown_records: 0,
|
|
389
|
+
unpriced_models: [],
|
|
390
|
+
};
|
|
391
|
+
const unpriced = new Set();
|
|
392
|
+
for (const record of records) {
|
|
393
|
+
if (record.kind === "turn_total")
|
|
394
|
+
continue;
|
|
395
|
+
if (session !== undefined && record.session !== session)
|
|
396
|
+
continue;
|
|
397
|
+
totals.records += 1;
|
|
398
|
+
if (record.price !== null) {
|
|
399
|
+
totals.currency = record.price.currency;
|
|
400
|
+
totals.price_version = record.price.version;
|
|
401
|
+
}
|
|
402
|
+
if (record.usage === null) {
|
|
403
|
+
totals.billed_unknown_records += 1;
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
totals.tokens = usageAdd(totals.tokens, record.usage);
|
|
407
|
+
if (record.priced_by === "unpriced") {
|
|
408
|
+
totals.unpriced_records += 1;
|
|
409
|
+
unpriced.add(record.model ?? "(unknown model)");
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (record.cost !== null)
|
|
413
|
+
totals.cost = totals.cost === null ? record.cost : costAdd(totals.cost, record.cost);
|
|
414
|
+
}
|
|
415
|
+
totals.unpriced_models = [...unpriced].sort();
|
|
416
|
+
totals.cost_complete = totals.unpriced_records === 0 && totals.billed_unknown_records === 0;
|
|
417
|
+
return totals;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* The process-level ledger file (one writer per process, every session books
|
|
421
|
+
* into it — §3.6). `CELESTEA_USAGE_LEDGER=off|0|false|no` disables it entirely;
|
|
422
|
+
* the price snapshot is read once here, so a new process picks up a new version
|
|
423
|
+
* while every already-written row keeps the one it was priced with (C7).
|
|
424
|
+
*/
|
|
425
|
+
export function createUsageLedgerFile(opts) {
|
|
426
|
+
const env = opts.env ?? process.env;
|
|
427
|
+
if (!ledgerEnabled(env))
|
|
428
|
+
return null;
|
|
429
|
+
const override = env[ENV_USAGE_LEDGER_FILE];
|
|
430
|
+
const path = override === undefined || override.trim() === "" ? join(opts.dataDir, USAGE_LEDGER_FILE) : override;
|
|
431
|
+
const pricing = loadPricingFile(pricingPath(opts.dataDir, env));
|
|
432
|
+
return new UsageLedgerFile({ path, pricing, ...(opts.now === undefined ? {} : { now: opts.now }) });
|
|
433
|
+
}
|
|
434
|
+
/** `off` (and the usual falsey spellings) turns the ledger off. */
|
|
435
|
+
export function ledgerEnabled(env = process.env) {
|
|
436
|
+
const raw = (env[ENV_USAGE_LEDGER] ?? "").trim().toLowerCase();
|
|
437
|
+
return !["off", "0", "false", "no"].includes(raw);
|
|
438
|
+
}
|
|
439
|
+
function warn(message) {
|
|
440
|
+
process.stderr.write(`usage ledger: ${message}\n`);
|
|
441
|
+
}
|
|
442
|
+
function errorText(e) {
|
|
443
|
+
return e instanceof Error ? e.message : String(e);
|
|
444
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pricing snapshot for the usage ledger (iteration E §3.2.2, W728 P0).
|
|
3
|
+
*
|
|
4
|
+
* The engine converts tokens into money with EXACTLY one rule:
|
|
5
|
+
* `tokens / 1e6 × unit price`. It deliberately does NOT re-implement the
|
|
6
|
+
* platform's billing (group multipliers, expressions, cache discounts):
|
|
7
|
+
* `newapi` owns the price of record (LTS `biz/newapi.md` I1–I3), so a snapshot
|
|
8
|
+
* here is an engine-side ESTIMATE, never a second source of truth (§3.5 R3-2).
|
|
9
|
+
*
|
|
10
|
+
* `pricing.json` is operator/ops supplied (§3.5 R3-1: P0 does not depend on the
|
|
11
|
+
* newapi sync script). A model absent from the table is NOT priced as 0:
|
|
12
|
+
* [priceFor] returns null and the caller must mark the record `unpriced`.
|
|
13
|
+
* A missing or unreadable file is an empty table (every model unpriced) — it is
|
|
14
|
+
* reported on stderr and never throws, because a pricing problem must not break
|
|
15
|
+
* a turn.
|
|
16
|
+
*/
|
|
17
|
+
import type { Usage } from "@celestea/core";
|
|
18
|
+
/** `<data dir>/pricing.json` (§3.2.2). */
|
|
19
|
+
export declare const PRICING_FILE = "pricing.json";
|
|
20
|
+
/** Path override (`CELESTEA_PRICING_FILE`). */
|
|
21
|
+
export declare const ENV_PRICING_FILE = "CELESTEA_PRICING_FILE";
|
|
22
|
+
/** The only unit the ledger understands (per million tokens). */
|
|
23
|
+
export declare const PRICING_UNIT = "per_mtok";
|
|
24
|
+
/** Unit prices of one model, in `PricingTable.currency`, per million tokens. */
|
|
25
|
+
export interface ModelPrice {
|
|
26
|
+
in: number;
|
|
27
|
+
out: number;
|
|
28
|
+
cache_read: number;
|
|
29
|
+
}
|
|
30
|
+
/** A loaded price table plus where it came from (never a bare number map). */
|
|
31
|
+
export interface PricingTable {
|
|
32
|
+
version: string;
|
|
33
|
+
currency: string;
|
|
34
|
+
unit: typeof PRICING_UNIT;
|
|
35
|
+
models: Record<string, ModelPrice>;
|
|
36
|
+
effective_from: number | null;
|
|
37
|
+
/** Absolute path the snapshot was read from; null = no table at all. */
|
|
38
|
+
path: string | null;
|
|
39
|
+
}
|
|
40
|
+
/** Money of one ledger row: the three components and their sum. */
|
|
41
|
+
export interface LedgerCost {
|
|
42
|
+
/**
|
|
43
|
+
* Cost of the UNCACHED input only: `max(0, prompt_tokens - cache_read) × in`.
|
|
44
|
+
* The prompt counter already contains the cache-hit region, so `in` is NOT
|
|
45
|
+
* `prompt_tokens × in` - the hit region is charged once, under [cache].
|
|
46
|
+
*/
|
|
47
|
+
in: number;
|
|
48
|
+
/** Cost of the completion tokens (`completion_tokens × out`). */
|
|
49
|
+
out: number;
|
|
50
|
+
/**
|
|
51
|
+
* Cost of the cache-hit region (`cache_read × cache_read`). Charged HERE
|
|
52
|
+
* only - never also at the input price.
|
|
53
|
+
*/
|
|
54
|
+
cache: number;
|
|
55
|
+
/** `in + out + cache` (the parts cannot drift from the total). */
|
|
56
|
+
total: number;
|
|
57
|
+
}
|
|
58
|
+
/** A table with no models: every lookup is `unpriced` (never 0). */
|
|
59
|
+
export declare function emptyPricing(path?: string | null): PricingTable;
|
|
60
|
+
/** `<data dir>/pricing.json`, overridable with `CELESTEA_PRICING_FILE`. */
|
|
61
|
+
export declare function pricingPath(dataDir: string, env?: NodeJS.ProcessEnv): string;
|
|
62
|
+
/** Price of `model`, or null when the snapshot does not cover it. */
|
|
63
|
+
export declare function priceFor(table: PricingTable, model: string | null): ModelPrice | null;
|
|
64
|
+
/**
|
|
65
|
+
* `uncached_in × in + completion × out + cache_read × cache_read`, rounded
|
|
66
|
+
* to 6 decimals.
|
|
67
|
+
*
|
|
68
|
+
* The provider's `prompt_tokens` ALREADY CONTAINS the cache-hit region (host
|
|
69
|
+
* turn-usage: `total - output === input + cacheRead + cacheWrite`; the LLM seam
|
|
70
|
+
* reads `prompt_tokens` as that total and the cache counters separately), so
|
|
71
|
+
* the hit region must be billed ONCE, at the cache price:
|
|
72
|
+
* `billableIn = max(0, prompt_tokens - cache_read)` is the input the provider
|
|
73
|
+
* did NOT serve from cache. Charging the whole prompt at the input price AND
|
|
74
|
+
* the cache counter on top would bill the hit region twice; that reading was
|
|
75
|
+
* rejected by the product, so this function never does it.
|
|
76
|
+
*
|
|
77
|
+
* `max(0, ...)` guards anomalous data (`cache_read > prompt_tokens`, which the
|
|
78
|
+
* provider should never report): the input component floors at 0 instead of
|
|
79
|
+
* going negative. The cache component still charges what was reported.
|
|
80
|
+
*/
|
|
81
|
+
export declare function costOf(usage: Usage, price: ModelPrice): LedgerCost;
|
|
82
|
+
/** Sum of two costs, component-wise (so the total cannot drift from the parts). */
|
|
83
|
+
export declare function costAdd(a: LedgerCost, b: LedgerCost): LedgerCost;
|
|
84
|
+
/** The frozen form written into every priced ledger row (a per-row snapshot). */
|
|
85
|
+
export interface PriceSnapshot extends ModelPrice {
|
|
86
|
+
version: string;
|
|
87
|
+
currency: string;
|
|
88
|
+
unit: string;
|
|
89
|
+
}
|
|
90
|
+
/** `price` field of a priced ledger row. */
|
|
91
|
+
export declare function priceSnapshot(table: PricingTable, price: ModelPrice): PriceSnapshot;
|
|
92
|
+
/** Parse a pricing.json document; null = malformed (caller falls back to empty). */
|
|
93
|
+
export declare function parsePricing(input: unknown, path?: string | null): PricingTable | null;
|
|
94
|
+
/**
|
|
95
|
+
* Read the snapshot. A missing file is an empty table (everything unpriced). A
|
|
96
|
+
* file that exists but does not parse is ALSO an empty table, reported on
|
|
97
|
+
* stderr: pricing half the fleet by a half-read table would under-report cost
|
|
98
|
+
* silently (G3-7).
|
|
99
|
+
*/
|
|
100
|
+
export declare function loadPricingFile(path: string): PricingTable;
|