@qilin-agent/token-meter 0.0.0-stage → 3.1.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/lib/index.js ADDED
@@ -0,0 +1,1010 @@
1
+ import { Service } from "@qilin-agent/kylin";
2
+ import z from "@qilin-agent/schemastery";
3
+ import { assembleAssistantStream, lastAssistantStreamChunk } from "@qilin-agent/llm";
4
+ import { deepFreeze } from "@qilin-agent/util-values";
5
+ import { SessionLogOffset, SessionSeq, canonicalHeader, deriveEventMessage, headerEquals, isSurfaceEvent } from "@qilin-agent/session";
6
+ import { z as z$1 } from "zod";
7
+ import { lastAssistantStreamChunk as lastAssistantStreamChunk$1 } from "@qilin-agent/llm/assistant-stream";
8
+ //#region lib/types/estimate.js
9
+ /**
10
+ * Fixed-density heuristic token pricing shared by the meter service and the
11
+ * pure context-breakdown projection, so both surfaces price identical content
12
+ * to identical numbers.
13
+ *
14
+ * @module @qilin-agent/token-meter/estimate
15
+ */
16
+ /** Fixed text-density estimate used until exact tokenization is needed. */
17
+ const CHARS_PER_TOKEN = 4;
18
+ /** Per-block structural overhead for JSON framing and type tags. */
19
+ const BLOCK_OVERHEAD = 4;
20
+ /**
21
+ * Structural JSON price of one block outside the typed pricing arms: the
22
+ * fixed heuristic for merge-extended blocks and for image references, whose
23
+ * request price is route-owned rather than fixed. Image offload marks do not
24
+ * change this reference-only heuristic; route pricing owns their placeholders.
25
+ * @param block - block to price without mutation.
26
+ * @returns heuristic tokens for the block's JSON structure.
27
+ */
28
+ function estimateStructuralBlock(block) {
29
+ if (block.type === "image") {
30
+ const { offloaded: _offloaded, ...reference } = block;
31
+ return BLOCK_OVERHEAD + Math.ceil(JSON.stringify(reference).length / CHARS_PER_TOKEN);
32
+ }
33
+ return BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN);
34
+ }
35
+ /**
36
+ * Price content blocks recursively under the fixed density heuristic.
37
+ * @param blocks - content blocks to price without mutation.
38
+ * @returns heuristic tokens including per-block structural overhead.
39
+ */
40
+ function estimateContent(blocks) {
41
+ let tokens = 0;
42
+ for (const block of blocks) switch (block.type) {
43
+ case "text":
44
+ case "reasoning":
45
+ tokens += Math.ceil(block.text.length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
46
+ break;
47
+ case "tool-call":
48
+ tokens += Math.ceil(block.name.length / CHARS_PER_TOKEN) + Math.ceil(block.arguments.length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
49
+ break;
50
+ default: tokens += estimateStructuralBlock(block);
51
+ }
52
+ return tokens;
53
+ }
54
+ /**
55
+ * Price the rendered system prompt: the `system/message` surface node's text.
56
+ * Adapters serialize the prompt as a plain string — a system-role message or
57
+ * the request's system field — not as a typed content block, so the price is
58
+ * text density plus role framing with no per-block overhead.
59
+ * @param message - system-role message to price without mutation.
60
+ * @returns heuristic system-prompt tokens; 0 for empty content ("no system prompt").
61
+ */
62
+ function estimateSystemMessage(message) {
63
+ if (message.content.length === 0) return 0;
64
+ let characters = 0;
65
+ for (const block of message.content) characters += block.type === "text" ? block.text.length : JSON.stringify(block).length;
66
+ return Math.ceil(characters / CHARS_PER_TOKEN) + 4;
67
+ }
68
+ /**
69
+ * Heuristically price one model-visible message.
70
+ * @param message - message to price without mutation.
71
+ * @returns content and role-framing tokens under the fixed heuristic; a
72
+ * system-role message prices as {@link estimateSystemMessage}.
73
+ */
74
+ function estimateMessage(message) {
75
+ if (message.role === "system") return estimateSystemMessage(message);
76
+ return estimateContent(message.content) + 4;
77
+ }
78
+ /**
79
+ * Price the tool-schema part of a canonical request envelope — the envelope's
80
+ * only priced field, since the system prompt is a surface node.
81
+ * @param header - canonical envelope, or undefined before any request.
82
+ * @returns heuristic tool-schema tokens; 0 when absent or empty.
83
+ */
84
+ function estimateToolsTokens(header) {
85
+ if (header?.tools === void 0 || header.tools.length === 0) return 0;
86
+ return Math.ceil(JSON.stringify(header.tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
87
+ }
88
+ //#endregion
89
+ //#region lib/types/surface-fold.js
90
+ /**
91
+ * Positional pricing shared by measurement and the context-breakdown fold:
92
+ * measurement retains attachment details for route pricing; breakdown keeps
93
+ * only retained node identities, heuristic prices, and system classification.
94
+ * The occupancy projection uses the scalar shadow-price protocol instead.
95
+ *
96
+ * The fold is a plan/commit pair: {@link planSurfaceTokens} runs every
97
+ * fallible step read-only and {@link commitSurfaceTokens} mutates in place,
98
+ * so a throw leaves the caller's state untouched and the same malformed
99
+ * event fails identically on every retry.
100
+ * Nodes also carry durable attachment occurrences and their structural prices,
101
+ * so `measure()` can price the request representation sent to the model.
102
+ *
103
+ * @module @qilin-agent/token-meter/surface-fold
104
+ */
105
+ /** Collect projected attachment occurrences and their structural prices. */
106
+ function collectProjectedAttachments(blocks, images, files) {
107
+ let imageTokens = 0;
108
+ let fileTokens = 0;
109
+ for (const block of blocks) if (block.type === "image") {
110
+ images.push(block);
111
+ imageTokens += estimateStructuralBlock(block);
112
+ } else if (block.type === "file") {
113
+ files.push(block.attachment);
114
+ fileTokens += estimateStructuralBlock(block);
115
+ }
116
+ return {
117
+ imageTokens,
118
+ fileTokens
119
+ };
120
+ }
121
+ /** Build one priced node from a surface event's derived message. */
122
+ function analyzeNode(seq, message) {
123
+ if (message === null) return {
124
+ seq,
125
+ heuristicTokens: 0,
126
+ imageStructuralTokens: 0,
127
+ fileStructuralTokens: 0,
128
+ images: [],
129
+ files: []
130
+ };
131
+ const heuristicTokens = estimateMessage(message);
132
+ const images = [];
133
+ const files = [];
134
+ const structural = collectProjectedAttachments(message.content, images, files);
135
+ return {
136
+ seq,
137
+ heuristicTokens,
138
+ imageStructuralTokens: structural.imageTokens,
139
+ fileStructuralTokens: structural.fileTokens,
140
+ images,
141
+ files
142
+ };
143
+ }
144
+ /**
145
+ * Validate and price one surface event without mutating the surface.
146
+ * @param nodes - the priced surface preceding this event, in model-visible order.
147
+ * @param event - the surface event to place.
148
+ * @returns the plan for {@link commitSurfaceTokens}.
149
+ * @throws when a replacement names a range absent from `nodes` — committed
150
+ * logs are surface-validated at append time, so an unresolvable range is log
151
+ * corruption and must fail loud rather than skip the event.
152
+ */
153
+ function planSurfaceTokens(nodes, event) {
154
+ const node = analyzeNode(event.seq, deriveEventMessage(event));
155
+ const tokens = node.heuristicTokens;
156
+ const op = event.surfaceOp;
157
+ if (op === "append") return {
158
+ tokens,
159
+ deltaTokens: tokens,
160
+ node,
161
+ target: "append"
162
+ };
163
+ const startIdx = nodes.findIndex((candidate) => candidate.seq === op.startSeq);
164
+ const endIdx = nodes.findIndex((candidate) => candidate.seq === op.endSeq);
165
+ if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) throw new Error(`token surface: replace at seq ${event.seq} has invalid current range ${op.startSeq}-${op.endSeq}`);
166
+ return {
167
+ tokens,
168
+ deltaTokens: tokens - nodes.slice(startIdx, endIdx + 1).reduce((total, candidate) => total + candidate.heuristicTokens, 0),
169
+ node,
170
+ target: {
171
+ startIdx,
172
+ endIdx
173
+ }
174
+ };
175
+ }
176
+ /**
177
+ * Apply one validated plan to the priced surface in place; infallible, so it
178
+ * cannot leave a half-applied surface behind.
179
+ * @param nodes - the exact priced surface the plan was built against.
180
+ * @param plan - the transition returned by {@link planSurfaceTokens}.
181
+ */
182
+ function commitSurfaceTokens(nodes, plan) {
183
+ if (plan.target === "append") {
184
+ nodes.push(plan.node);
185
+ return;
186
+ }
187
+ nodes.splice(plan.target.startIdx, plan.target.endIdx - plan.target.startIdx + 1, plan.node);
188
+ }
189
+ //#endregion
190
+ //#region lib/types/breakdown-projection.js
191
+ /**
192
+ * Heuristic composition of the current retained surface, independent of route
193
+ * image pricing and provider usage. Positional entries preserve system-prompt
194
+ * classification across replacements without retaining historical messages.
195
+ */
196
+ const tokenCount = z$1.number().int().nonnegative();
197
+ const sessionSeq = z$1.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(SessionSeq);
198
+ const breakdownSchema = z$1.object({
199
+ systemTokens: tokenCount,
200
+ toolsTokens: tokenCount,
201
+ messageTokens: tokenCount
202
+ }).strict();
203
+ /**
204
+ * Context composition with the last nonempty surviving system in surface
205
+ * order classified as system tokens; all other visible prices are messages.
206
+ * Replacements use the measurement planner, not shadow-price claims. State
207
+ * and surface transitions cost O(current retained surface), not O(log length).
208
+ * Tools are priced from the latest request header. No route pricing applies.
209
+ */
210
+ const contextBreakdownProjectionDefinition = {
211
+ key: "contextBreakdown",
212
+ stateVersion: 5,
213
+ stateSchema: z$1.object({
214
+ nodes: z$1.array(z$1.object({
215
+ seq: sessionSeq,
216
+ heuristicTokens: tokenCount,
217
+ system: z$1.boolean()
218
+ }).strict()),
219
+ breakdown: breakdownSchema
220
+ }).strict(),
221
+ init: () => ({
222
+ nodes: [],
223
+ breakdown: {
224
+ systemTokens: 0,
225
+ toolsTokens: 0,
226
+ messageTokens: 0
227
+ }
228
+ }),
229
+ apply: (state, event) => {
230
+ if (event.type === "request/header") {
231
+ const toolsTokens = estimateToolsTokens(canonicalHeader(event.data.header));
232
+ return toolsTokens === state.breakdown.toolsTokens ? state : {
233
+ ...state,
234
+ breakdown: {
235
+ ...state.breakdown,
236
+ toolsTokens
237
+ }
238
+ };
239
+ }
240
+ if (!isSurfaceEvent(event)) return state;
241
+ const plan = planSurfaceTokens(state.nodes, event);
242
+ const nodes = [...state.nodes];
243
+ commitSurfaceTokens(nodes, {
244
+ ...plan,
245
+ node: {
246
+ seq: event.seq,
247
+ heuristicTokens: plan.tokens,
248
+ system: event.type === "system/message"
249
+ }
250
+ });
251
+ const systemTokens = nodes.findLast((node) => node.system && node.heuristicTokens > 0)?.heuristicTokens ?? 0;
252
+ const messageTokens = state.breakdown.systemTokens + state.breakdown.messageTokens + plan.deltaTokens - systemTokens;
253
+ return {
254
+ nodes,
255
+ breakdown: systemTokens === state.breakdown.systemTokens && messageTokens === state.breakdown.messageTokens ? state.breakdown : {
256
+ systemTokens,
257
+ toolsTokens: state.breakdown.toolsTokens,
258
+ messageTokens
259
+ }
260
+ };
261
+ },
262
+ wire: {
263
+ viewSchema: breakdownSchema,
264
+ view: (state) => state.breakdown
265
+ }
266
+ };
267
+ //#endregion
268
+ //#region lib/types/surface-projection.js
269
+ /**
270
+ * The context-pressure projection's O(1) surface-token fold.
271
+ *
272
+ * Its checkpoint keeps scalar totals rather than the retained surface used
273
+ * by context breakdown. Replacements ride the compact
274
+ * seam's shadow-price protocol: the metering event immediately before a
275
+ * surface `replace` (`compaction/summary` or `compaction/prune`) states the
276
+ * heuristic price of the exact replaced range, so the fold keeps a running
277
+ * total plus at most one pending claim and never retains per-node prices.
278
+ * The counts are exact by construction: producers derive them from the same
279
+ * fixed estimator this module prices appends with. A replacement without an
280
+ * armed claim folds with zero delta because bounded state cannot reconstruct
281
+ * the replaced range; this preserves replay at the cost of possible drift.
282
+ *
283
+ * @module @qilin-agent/token-meter/surface-projection
284
+ */
285
+ /**
286
+ * Fold one committed event onto a running surface-token total.
287
+ *
288
+ * A shadow-price event arms a claim; any other event expires it, and a
289
+ * surface `replace` consumes the claim naming its exact range — the
290
+ * producers append the metering event and the replacement synchronously
291
+ * adjacent, so a surviving claim always prices the very next event.
292
+ * A replace with no claim folds with zero delta because the bounded state
293
+ * cannot reconstruct the replaced range. An armed claim for another range
294
+ * still fails because the adjacent events contradict each other.
295
+ * @param claim - the claim armed by the immediately preceding event, if any.
296
+ * @param event - the next committed session event.
297
+ * @returns the signed token delta and the claim state after this event.
298
+ * @throws when a replacement arrives with an armed claim for a different
299
+ * range — the metering event was adjacent, so this is a live producer's
300
+ * shadow-price contract violation, not historical data, and must fail
301
+ * loud rather than let the total drift.
302
+ */
303
+ function foldSurfaceProjection(claim, event) {
304
+ if (event.type === "compaction/summary" || event.type === "compaction/prune") {
305
+ const { shadowedRange, shadowedTokenCount } = event.data;
306
+ return {
307
+ deltaTokens: 0,
308
+ claim: {
309
+ start: SessionSeq(shadowedRange.start),
310
+ end: SessionSeq(shadowedRange.end),
311
+ tokens: shadowedTokenCount
312
+ }
313
+ };
314
+ }
315
+ if (!isSurfaceEvent(event)) return {
316
+ deltaTokens: 0,
317
+ claim: void 0
318
+ };
319
+ const message = deriveEventMessage(event);
320
+ const tokens = message === null ? 0 : estimateMessage(message);
321
+ const op = event.surfaceOp;
322
+ if (op === "append") return {
323
+ deltaTokens: tokens,
324
+ claim: void 0
325
+ };
326
+ if (claim === void 0) return {
327
+ deltaTokens: 0,
328
+ claim: void 0
329
+ };
330
+ if (claim.start !== op.startSeq || claim.end !== op.endSeq) throw new Error(`token surface: replace at seq ${event.seq} over range ${op.startSeq}-${op.endSeq} has no adjacent shadow price (armed claim covers ${claim.start}-${claim.end})`);
331
+ return {
332
+ deltaTokens: tokens - claim.tokens,
333
+ claim: void 0
334
+ };
335
+ }
336
+ //#endregion
337
+ //#region lib/types/usage-projection.js
338
+ /**
339
+ * Pure folds for durable provider-reported token usage and context occupancy.
340
+ */
341
+ const zeroBuckets = () => ({
342
+ uncachedInputTokens: 0,
343
+ outputTokens: 0,
344
+ cacheReadTokens: 0,
345
+ cacheWriteTokens: 0
346
+ });
347
+ const bucketsFrom = (usage) => ({
348
+ uncachedInputTokens: usage.inputTokens,
349
+ outputTokens: usage.outputTokens,
350
+ cacheReadTokens: usage.cacheReadTokens ?? 0,
351
+ cacheWriteTokens: usage.cacheWriteTokens ?? 0
352
+ });
353
+ const bucketsEqual = (left, right) => left.uncachedInputTokens === right.uncachedInputTokens && left.outputTokens === right.outputTokens && left.cacheReadTokens === right.cacheReadTokens && left.cacheWriteTokens === right.cacheWriteTokens;
354
+ const addReplacing = (totals, previous, next) => ({
355
+ uncachedInputTokens: totals.uncachedInputTokens - (previous?.uncachedInputTokens ?? 0) + next.uncachedInputTokens,
356
+ outputTokens: totals.outputTokens - (previous?.outputTokens ?? 0) + next.outputTokens,
357
+ cacheReadTokens: totals.cacheReadTokens - (previous?.cacheReadTokens ?? 0) + next.cacheReadTokens,
358
+ cacheWriteTokens: totals.cacheWriteTokens - (previous?.cacheWriteTokens ?? 0) + next.cacheWriteTokens
359
+ });
360
+ const projectionSchema = z$1.object({
361
+ uncachedInputTokens: z$1.number().int().nonnegative(),
362
+ outputTokens: z$1.number().int().nonnegative(),
363
+ cacheReadTokens: z$1.number().int().nonnegative(),
364
+ cacheWriteTokens: z$1.number().int().nonnegative()
365
+ }).strict();
366
+ /**
367
+ * The token-usage unit's state schema — the one definition of the state
368
+ * shape; the state type is inferred from it.
369
+ */
370
+ const tokenUsageStateSchema = z$1.object({
371
+ totals: projectionSchema,
372
+ last: z$1.object({
373
+ turn: z$1.number().int().nonnegative(),
374
+ step: z$1.number().int().nonnegative(),
375
+ buckets: projectionSchema
376
+ }).nullable()
377
+ }).strict();
378
+ const pressureSchema = z$1.object({
379
+ pressureTokens: z$1.number().int().nonnegative().optional(),
380
+ projectedTokens: z$1.number().int().nonnegative().optional(),
381
+ contextWindow: z$1.number().int().positive().optional()
382
+ }).strict().transform(({ pressureTokens, projectedTokens, contextWindow }) => ({
383
+ ...pressureTokens === void 0 ? {} : { pressureTokens },
384
+ ...projectedTokens === void 0 ? {} : { projectedTokens },
385
+ ...contextWindow === void 0 ? {} : { contextWindow }
386
+ }));
387
+ /** Prompt-side pressure of one request: input plus cache traffic, no output. */
388
+ const pressureFrom = (usage) => usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
389
+ /** The usage one durable Assistant settlement reports for its attempt, if any. */
390
+ function usageOf(event) {
391
+ if (event.type === "assistant/message" && event.data.usage !== void 0) return event.data.usage;
392
+ if (event.type !== "assistant/message" && event.type !== "assistant/attempt") return void 0;
393
+ return lastAssistantStreamChunk(event.data.stream, "usage")?.usage;
394
+ }
395
+ /** The context-pressure state schema and source of its inferred type. */
396
+ const contextPressureStateSchema = z$1.object({
397
+ contextWindow: z$1.number().int().positive().optional(),
398
+ pressureTokens: z$1.number().int().nonnegative().optional(),
399
+ surfaceTokens: z$1.number().int().nonnegative(),
400
+ sampledSurfaceTokens: z$1.number().int().nonnegative().optional(),
401
+ claim: z$1.object({
402
+ start: z$1.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(SessionSeq),
403
+ end: z$1.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(SessionSeq),
404
+ tokens: z$1.number().int().nonnegative()
405
+ }).optional()
406
+ }).strict();
407
+ /**
408
+ * Token-meter's session projection unit.
409
+ *
410
+ * Each v2 Assistant settlement contributes the last usage sample embedded in
411
+ * its stream. `llm/retry-started` closes the replacement slot so the retried
412
+ * attempt adds to the total.
413
+ */
414
+ const tokenUsageProjectionDefinition = {
415
+ key: "tokenUsage",
416
+ stateVersion: 2,
417
+ stateSchema: tokenUsageStateSchema,
418
+ init: () => ({
419
+ totals: zeroBuckets(),
420
+ last: null
421
+ }),
422
+ apply: (state, event) => {
423
+ if (event.type === "llm/retry-started") return state.last?.turn === event.data.turn && state.last.step === event.data.step ? {
424
+ ...state,
425
+ last: null
426
+ } : state;
427
+ if (event.type !== "assistant/message" && event.type !== "assistant/attempt") return state;
428
+ const sample = usageOf(event);
429
+ if (sample === void 0) return state;
430
+ const { turn, step } = event.data;
431
+ const buckets = bucketsFrom(sample);
432
+ const previous = state.last !== null && state.last.turn === turn && state.last.step === step ? state.last.buckets : void 0;
433
+ if (previous !== void 0 && bucketsEqual(previous, buckets)) return state;
434
+ return {
435
+ totals: addReplacing(state.totals, previous, buckets),
436
+ last: {
437
+ turn,
438
+ step,
439
+ buckets
440
+ }
441
+ };
442
+ },
443
+ wire: {
444
+ viewSchema: projectionSchema,
445
+ view: (state) => state.totals
446
+ }
447
+ };
448
+ /**
449
+ * Token-meter's context-occupancy projection unit.
450
+ *
451
+ * Independent last-wins slots: the newest usage sample supplies the provider
452
+ * numerator, the newest `request/context` record the denominator. Both are
453
+ * whole values, so replay order alone decides the result and no cross-field
454
+ * consistency is claimed — the pair is explicitly not one atomic request
455
+ * observation (see {@link ContextPressureProjection}).
456
+ *
457
+ * `pressureTokens` is prompt-side only, so it holds still while a turn streams
458
+ * and steps forward once the next request reports its usage. Because nothing
459
+ * but a request reports usage, it also cannot see a compaction: the fold
460
+ * therefore carries a running surface total alongside it and publishes
461
+ * `projectedTokens` — the sample plus the surface's signed movement since it
462
+ * was taken — so occupancy answers for the next request rather than the last
463
+ * one. The total rides {@link foldSurfaceProjection}, so the state stays O(1)
464
+ * and a replacement shrinks it by its logged shadow price. A replacement
465
+ * without a claim preserves the previous total. A usage sample is stamped
466
+ * BEFORE the same event joins the surface, so an `assistant/message` anchors
467
+ * against the surface its own request saw.
468
+ */
469
+ const contextPressureProjectionDefinition = {
470
+ key: "contextPressure",
471
+ stateVersion: 5,
472
+ stateSchema: contextPressureStateSchema,
473
+ init: () => ({ surfaceTokens: 0 }),
474
+ apply: (state, event) => {
475
+ const fold = foldSurfaceProjection(state.claim, event);
476
+ let next = state;
477
+ if (event.type === "request/context") {
478
+ const contextWindow = event.data.contextWindow;
479
+ if (contextWindow !== state.contextWindow) if (contextWindow !== void 0) next = {
480
+ ...next,
481
+ contextWindow
482
+ };
483
+ else {
484
+ const { contextWindow: _removed, ...withoutContextWindow } = next;
485
+ next = withoutContextWindow;
486
+ }
487
+ }
488
+ const usage = usageOf(event);
489
+ if (usage !== void 0) {
490
+ const pressureTokens = pressureFrom(usage);
491
+ if (pressureTokens !== next.pressureTokens || next.sampledSurfaceTokens !== next.surfaceTokens) next = {
492
+ ...next,
493
+ pressureTokens,
494
+ sampledSurfaceTokens: next.surfaceTokens
495
+ };
496
+ }
497
+ if (fold.deltaTokens !== 0) next = {
498
+ ...next,
499
+ surfaceTokens: next.surfaceTokens + fold.deltaTokens
500
+ };
501
+ if (state.claim === void 0 && fold.claim === void 0) return next;
502
+ const { claim: _expired, ...withoutClaim } = next;
503
+ return fold.claim === void 0 ? withoutClaim : {
504
+ ...withoutClaim,
505
+ claim: fold.claim
506
+ };
507
+ },
508
+ wire: {
509
+ viewSchema: pressureSchema,
510
+ view: ({ contextWindow, pressureTokens, surfaceTokens, sampledSurfaceTokens }) => ({
511
+ ...contextWindow === void 0 ? {} : { contextWindow },
512
+ ...pressureTokens === void 0 ? {} : { pressureTokens },
513
+ ...pressureTokens === void 0 || sampledSurfaceTokens === void 0 ? {} : { projectedTokens: Math.max(0, pressureTokens + surfaceTokens - sampledSurfaceTokens) }
514
+ })
515
+ }
516
+ };
517
+ //#endregion
518
+ //#region lib/types/route-pricing.js
519
+ /**
520
+ * Request-projected surface pricing: replaces attachment-block heuristics with
521
+ * the image and file representations sent to the routed model.
522
+ *
523
+ * @module @qilin-agent/token-meter/route-pricing
524
+ */
525
+ /**
526
+ * Price one ordered surface under its model-request attachment projection.
527
+ * @param nodes - the fold's current or snapshotted surface, in model-visible order.
528
+ * @param pricing - the routed model's image pricing, or undefined to keep the fixed heuristic.
529
+ * @param fileText - exact file handle projection used by the mounted LLM service.
530
+ * @returns detached public nodes and their route-priced total.
531
+ * @throws when the pricing answers a different occurrence count than it was
532
+ * asked — misalignment would silently misprice nodes, so it must fail loud.
533
+ */
534
+ function priceSurface(nodes, pricing, fileText) {
535
+ const images = pricing === void 0 ? [] : nodes.flatMap((node) => node.images);
536
+ const hasFiles = fileText !== void 0 && nodes.some((node) => node.files.length > 0);
537
+ if ((pricing === void 0 || images.length === 0) && !hasFiles) {
538
+ let surfaceTokens = 0;
539
+ return {
540
+ nodes: nodes.map((node) => {
541
+ surfaceTokens += node.heuristicTokens;
542
+ return {
543
+ seq: node.seq,
544
+ tokens: node.heuristicTokens,
545
+ heuristicTokens: node.heuristicTokens
546
+ };
547
+ }),
548
+ surfaceTokens
549
+ };
550
+ }
551
+ const prices = pricing === void 0 ? [] : pricing.priceImages(images);
552
+ if (pricing !== void 0 && prices.length !== images.length) throw new Error(`token meter: route image pricing answered ${prices.length} prices for ${images.length} occurrences`);
553
+ let cursor = 0;
554
+ let surfaceTokens = 0;
555
+ return {
556
+ nodes: nodes.map((node) => {
557
+ let tokens = node.heuristicTokens;
558
+ if (fileText !== void 0 && node.files.length > 0) {
559
+ tokens -= node.fileStructuralTokens;
560
+ for (const file of node.files) tokens += estimateContent([{
561
+ type: "text",
562
+ text: fileText(file)
563
+ }]);
564
+ }
565
+ if (pricing !== void 0 && node.images.length > 0) {
566
+ tokens -= node.imageStructuralTokens;
567
+ for (let occurrence = 0; occurrence < node.images.length; occurrence += 1) {
568
+ const price = prices[cursor];
569
+ cursor += 1;
570
+ tokens += price.visualTokens + estimateContent([{
571
+ type: "text",
572
+ text: price.text
573
+ }]);
574
+ }
575
+ }
576
+ surfaceTokens += tokens;
577
+ return {
578
+ seq: node.seq,
579
+ tokens,
580
+ heuristicTokens: node.heuristicTokens
581
+ };
582
+ }),
583
+ surfaceTokens
584
+ };
585
+ }
586
+ //#endregion
587
+ //#region lib/types/turn-usage.js
588
+ function isCount(value) {
589
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
590
+ }
591
+ function safeSum(values) {
592
+ let total = 0;
593
+ for (const value of values) {
594
+ total += value;
595
+ if (!Number.isSafeInteger(total)) return void 0;
596
+ }
597
+ return total;
598
+ }
599
+ function messageRoute(message) {
600
+ const { provider, model } = message.source;
601
+ return provider.length > 0 && model.length > 0 ? {
602
+ provider,
603
+ model
604
+ } : void 0;
605
+ }
606
+ function streamUsage(stream) {
607
+ return lastAssistantStreamChunk$1(stream, "usage")?.usage;
608
+ }
609
+ function normalizeUsage(usage, route) {
610
+ const { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens, totalTokens } = usage;
611
+ if (!isCount(inputTokens) || !isCount(outputTokens)) return void 0;
612
+ if (cacheReadTokens !== void 0 && !isCount(cacheReadTokens)) return void 0;
613
+ if (cacheWriteTokens !== void 0 && !isCount(cacheWriteTokens)) return void 0;
614
+ if (reasoningTokens !== void 0 && (!isCount(reasoningTokens) || reasoningTokens > outputTokens)) return;
615
+ const knownPrompt = safeSum([
616
+ inputTokens,
617
+ ...cacheReadTokens === void 0 ? [] : [cacheReadTokens],
618
+ ...cacheWriteTokens === void 0 ? [] : [cacheWriteTokens]
619
+ ]);
620
+ if (knownPrompt === void 0) return void 0;
621
+ let exactTotal;
622
+ if (totalTokens !== void 0) {
623
+ if (!isCount(totalTokens)) return void 0;
624
+ const exactPrompt = totalTokens - outputTokens;
625
+ if (!isCount(exactPrompt) || exactPrompt < knownPrompt) return void 0;
626
+ if (cacheReadTokens !== void 0 && cacheWriteTokens !== void 0 && exactPrompt !== knownPrompt) return;
627
+ exactTotal = totalTokens;
628
+ } else {
629
+ if (cacheReadTokens === void 0 || cacheWriteTokens === void 0) return void 0;
630
+ const derivedTotal = safeSum([knownPrompt, outputTokens]);
631
+ if (derivedTotal === void 0) return void 0;
632
+ exactTotal = derivedTotal;
633
+ }
634
+ return {
635
+ inputTokens,
636
+ outputTokens,
637
+ totalTokens: exactTotal,
638
+ ...cacheReadTokens === void 0 ? {} : { cacheReadTokens },
639
+ ...cacheWriteTokens === void 0 ? {} : { cacheWriteTokens },
640
+ ...reasoningTokens === void 0 ? {} : { reasoningTokens },
641
+ ...route === void 0 ? {} : { route }
642
+ };
643
+ }
644
+ function aggregateAttempts(attempts) {
645
+ if (attempts.length === 0) return void 0;
646
+ const inputTokens = safeSum(attempts.map((attempt) => attempt.inputTokens));
647
+ const outputTokens = safeSum(attempts.map((attempt) => attempt.outputTokens));
648
+ const totalTokens = safeSum(attempts.map((attempt) => attempt.totalTokens));
649
+ if (inputTokens === void 0 || outputTokens === void 0 || totalTokens === void 0) return void 0;
650
+ const cacheRead = attempts.map((attempt) => attempt.cacheReadTokens);
651
+ const cacheWrite = attempts.map((attempt) => attempt.cacheWriteTokens);
652
+ const reasoning = attempts.map((attempt) => attempt.reasoningTokens);
653
+ const cacheReadTokens = cacheRead.every(isCount) ? safeSum(cacheRead) : void 0;
654
+ const cacheWriteTokens = cacheWrite.every(isCount) ? safeSum(cacheWrite) : void 0;
655
+ const reasoningTokens = reasoning.every(isCount) ? safeSum(reasoning) : void 0;
656
+ let routes;
657
+ const attributed = attempts.map((attempt) => attempt.route);
658
+ if (attributed.every((route) => route !== void 0)) {
659
+ const unique = /* @__PURE__ */ new Map();
660
+ for (const route of attributed) unique.set(`${route.provider}\0${route.model}`, route);
661
+ routes = [...unique.values()];
662
+ }
663
+ return {
664
+ uncachedInputTokens: inputTokens,
665
+ outputTokens,
666
+ totalTokens,
667
+ ...cacheReadTokens === void 0 ? {} : { cacheReadTokens },
668
+ ...cacheWriteTokens === void 0 ? {} : { cacheWriteTokens },
669
+ ...reasoningTokens === void 0 ? {} : { reasoningTokens },
670
+ ...routes === void 0 ? {} : { routes }
671
+ };
672
+ }
673
+ function sameAttempt(state, turn, step) {
674
+ return state.turn === turn && state.step === step;
675
+ }
676
+ /**
677
+ * Fold one complete Turn's durable attempt lifecycle into exact token accounting.
678
+ *
679
+ * No attempt is inferred from a usage sample. Any missing lifecycle boundary,
680
+ * incomplete attempt usage, unsafe count, or contradictory exact total makes
681
+ * the whole disclosure unavailable.
682
+ * @param events - Turn-local durable events from `turn/start` through `turn/end`.
683
+ * @returns exact aggregate usage, or undefined when it cannot be proven.
684
+ */
685
+ function deriveTurnTokenUsage(events) {
686
+ let state = { kind: "idle" };
687
+ const attempts = [];
688
+ let turn;
689
+ let sawEnd = false;
690
+ let invalid = false;
691
+ const closeOpen = (route) => {
692
+ if (state.kind !== "open" || state.sample === void 0) return false;
693
+ const normalized = normalizeUsage(state.sample, route);
694
+ if (normalized === void 0) return false;
695
+ attempts.push(normalized);
696
+ return true;
697
+ };
698
+ for (const event of events) {
699
+ if (invalid) break;
700
+ if (event.type === "turn/start") {
701
+ if (turn !== void 0 || state.kind !== "idle") invalid = true;
702
+ else turn = event.data.turn;
703
+ continue;
704
+ }
705
+ if (turn === void 0) {
706
+ invalid = true;
707
+ break;
708
+ }
709
+ if (event.type === "turn/end") {
710
+ if (event.data.turn !== turn || state.kind !== "idle" || sawEnd) invalid = true;
711
+ else sawEnd = true;
712
+ continue;
713
+ }
714
+ if (sawEnd) {
715
+ invalid = true;
716
+ break;
717
+ }
718
+ if (event.type === "step/start") {
719
+ if (event.data.turn !== turn || state.kind !== "idle") invalid = true;
720
+ else state = {
721
+ kind: "open",
722
+ turn,
723
+ step: event.data.step
724
+ };
725
+ continue;
726
+ }
727
+ if (event.type === "llm/retry-started") {
728
+ if (event.data.turn !== turn || state.kind !== "settled" || state.by !== "retry" || !sameAttempt(state, event.data.turn, event.data.step)) invalid = true;
729
+ else state = {
730
+ kind: "open",
731
+ turn,
732
+ step: event.data.step
733
+ };
734
+ continue;
735
+ }
736
+ if (event.type === "assistant/attempt") {
737
+ if (event.data.turn !== turn || state.kind !== "open" || !sameAttempt(state, event.data.turn, event.data.step)) {
738
+ invalid = true;
739
+ continue;
740
+ }
741
+ const sample = streamUsage(event.data.stream) ?? state.sample;
742
+ state = {
743
+ kind: "open",
744
+ turn,
745
+ step: event.data.step,
746
+ ...sample === void 0 ? {} : { sample }
747
+ };
748
+ if (!closeOpen()) invalid = true;
749
+ else state = {
750
+ kind: "finishClosed",
751
+ turn,
752
+ step: event.data.step
753
+ };
754
+ continue;
755
+ }
756
+ if (event.type === "assistant/message") {
757
+ if (event.data.turn !== turn || state.kind !== "open" || !sameAttempt(state, event.data.turn, event.data.step)) {
758
+ invalid = true;
759
+ continue;
760
+ }
761
+ const sample = event.data.usage ?? streamUsage(event.data.stream);
762
+ if (sample !== void 0) state = {
763
+ ...state,
764
+ sample
765
+ };
766
+ if (!closeOpen(messageRoute(event.data.message))) invalid = true;
767
+ else state = {
768
+ kind: "settled",
769
+ turn,
770
+ step: event.data.step,
771
+ by: "message"
772
+ };
773
+ continue;
774
+ }
775
+ if (event.type === "llm/retry") {
776
+ if (event.data.turn !== turn || state.kind === "idle" || !sameAttempt(state, event.data.turn, event.data.step)) {
777
+ invalid = true;
778
+ continue;
779
+ }
780
+ if (state.kind === "settled" || state.kind === "open" && !closeOpen()) invalid = true;
781
+ if (!invalid) state = {
782
+ kind: "settled",
783
+ turn,
784
+ step: event.data.step,
785
+ by: "retry"
786
+ };
787
+ continue;
788
+ }
789
+ if (event.type === "step/end") {
790
+ if (event.data.turn !== turn || state.kind === "idle" || !sameAttempt(state, event.data.turn, event.data.step)) {
791
+ invalid = true;
792
+ continue;
793
+ }
794
+ if (state.kind === "open" && !closeOpen()) invalid = true;
795
+ if (!invalid) state = { kind: "idle" };
796
+ }
797
+ }
798
+ return invalid || !sawEnd || state.kind !== "idle" ? void 0 : aggregateAttempts(attempts);
799
+ }
800
+ //#endregion
801
+ //#region lib/types/index.js
802
+ /**
803
+ * Single replay-aware token-meter service for request and surface pressure.
804
+ *
805
+ * @module @qilin-agent/token-meter
806
+ */
807
+ /** Sum disjoint provider usage buckets without double-counting reasoning output. */
808
+ function usageTokens(usage) {
809
+ return usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0) + usage.outputTokens;
810
+ }
811
+ /** Compare optional envelopes so a headerless estimate can track later surface deltas. */
812
+ function optionalHeaderEquals(left, right) {
813
+ if (left === void 0 || right === void 0) return left === right;
814
+ return headerEquals(left, right);
815
+ }
816
+ /** Reject stale or misspelled keys before defaults can hide them. */
817
+ function validateConfigKeys(config) {
818
+ for (const key of Object.keys(config)) throw new Error(`TokenMeterConfig: unknown key "${key}" (no settings are supported)`);
819
+ }
820
+ /** Replay owner for one service-wide estimator and isolated per-session folds. */
821
+ var TokenMeter = class extends Service {
822
+ static Config = z.object({});
823
+ static inject = ["sessionProjections"];
824
+ states = /* @__PURE__ */ new WeakMap();
825
+ constructor(ctx, config = {}) {
826
+ super(ctx, "tokenMeter");
827
+ validateConfigKeys(config);
828
+ ctx.sessionProjections.register(tokenUsageProjectionDefinition);
829
+ ctx.sessionProjections.register(contextPressureProjectionDefinition);
830
+ ctx.sessionProjections.register(contextBreakdownProjectionDefinition);
831
+ ctx.on("session/event", (session) => {
832
+ if (this.states.has(session)) this._sync(session);
833
+ });
834
+ }
835
+ /**
836
+ * Measure current request pressure and surface through the durable tail.
837
+ *
838
+ * The effective envelope's routed provider/model selects the request-image
839
+ * pricing every node is priced under: a route whose adapter declares image
840
+ * pricing charges each retained image its visual tokens plus its
841
+ * model-visible text, while other routes keep the fixed heuristic. Provider
842
+ * usage is reused only when the latest successful call's canonical request
843
+ * envelope matches `requestHeader` and its total is no lower than that
844
+ * call's full route-priced anchor; otherwise the complete envelope and
845
+ * surface are repriced. The anchor includes all surface nodes immediately
846
+ * before the assistant message, including inputs admitted after step/start.
847
+ *
848
+ * `requestHeader` replaces the latest logged envelope for pressure and node
849
+ * pricing; the node set always describes the current session surface. Every
850
+ * call clones those positional nodes, so measurement is O(surface).
851
+ *
852
+ * @param session - session to replay through its current durable tail.
853
+ * @param requestHeader - optional effective request envelope replacing the latest logged header.
854
+ * @returns a detached deeply immutable pressure and surface measurement.
855
+ */
856
+ measure(session, requestHeader) {
857
+ const state = this._sync(session);
858
+ const header = requestHeader === void 0 ? state.header : canonicalHeader(requestHeader);
859
+ const pricing = this._routeImagePricing(header);
860
+ const fileText = this._fileRequestText();
861
+ const surface = priceSurface(state.surface, pricing, fileText);
862
+ const anchor = state.anchor;
863
+ let baseline;
864
+ let surfaceDeltaTokens;
865
+ if (anchor !== void 0 && optionalHeaderEquals(anchor.header, header)) {
866
+ const anchorSurfaceTokens = priceSurface(anchor.nodes, pricing, fileText).surfaceTokens + anchor.assistantTokens;
867
+ const estimatedAnchorTokens = estimateToolsTokens(header) + anchorSurfaceTokens;
868
+ const usage = anchor.usage;
869
+ baseline = usage !== void 0 && usageTokens(usage) >= estimatedAnchorTokens ? {
870
+ kind: "usage",
871
+ tokens: usageTokens(usage),
872
+ usage
873
+ } : {
874
+ kind: "estimated",
875
+ tokens: estimatedAnchorTokens
876
+ };
877
+ surfaceDeltaTokens = surface.surfaceTokens - anchorSurfaceTokens;
878
+ } else if (header === void 0 && surface.surfaceTokens === 0) {
879
+ baseline = {
880
+ kind: "none",
881
+ tokens: 0
882
+ };
883
+ surfaceDeltaTokens = 0;
884
+ } else {
885
+ baseline = {
886
+ kind: "estimated",
887
+ tokens: estimateToolsTokens(header) + surface.surfaceTokens
888
+ };
889
+ surfaceDeltaTokens = 0;
890
+ }
891
+ return deepFreeze(structuredClone({
892
+ logRevision: state.consumedEvents,
893
+ baseline,
894
+ surfaceDeltaTokens,
895
+ totalTokens: Math.max(0, baseline.tokens + surfaceDeltaTokens),
896
+ surfaceTokens: surface.surfaceTokens,
897
+ nodes: surface.nodes
898
+ }));
899
+ }
900
+ /** Resolve the routed model's image pricing, when the llm service and route declare one. */
901
+ _routeImagePricing(header) {
902
+ const config = header?.config;
903
+ if (config === void 0) return void 0;
904
+ return this.ctx.get("llm")?.imageRequestPricing(config.provider, config.model);
905
+ }
906
+ /** Resolve request-time file projection when an LLM service is mounted. */
907
+ _fileRequestText() {
908
+ const llm = this.ctx.get("llm");
909
+ return llm === void 0 ? void 0 : (ref) => llm.fileRequestText(ref);
910
+ }
911
+ /**
912
+ * Heuristically price one model-visible message (instance face of the pure
913
+ * `estimateMessage` export from `estimate.ts`).
914
+ * @param message - message to price without mutation.
915
+ * @returns content and role-framing tokens under the fixed service heuristic.
916
+ */
917
+ estimateMessage(message) {
918
+ return estimateMessage(message);
919
+ }
920
+ /** Catch one session's fold up to the current durable tail. */
921
+ _sync(session) {
922
+ let state = this.states.get(session);
923
+ if (state === void 0) {
924
+ state = {
925
+ consumedEvents: SessionLogOffset(0),
926
+ header: void 0,
927
+ surface: [],
928
+ stepStart: void 0,
929
+ anchor: void 0
930
+ };
931
+ this.states.set(session, state);
932
+ }
933
+ while (state.consumedEvents < session.seq) {
934
+ const event = session.eventAt(SessionSeq(state.consumedEvents));
935
+ this._foldEvent(state, event);
936
+ state.consumedEvents = SessionLogOffset(state.consumedEvents + 1);
937
+ }
938
+ return state;
939
+ }
940
+ /**
941
+ * Run every fallible step — surface plan and anchor validation — before
942
+ * mutating replay state, so a malformed event remains unread on every
943
+ * retry instead of half-applying.
944
+ */
945
+ _foldEvent(state, event) {
946
+ let nextHeader = state.header;
947
+ let nextStepStart = state.stepStart;
948
+ let nextAnchor = state.anchor;
949
+ switch (event.type) {
950
+ case "image/offload": {
951
+ const offloaded = new Map(event.data.targets.map((target) => [target.seq, new Set(target.imageIndexes)]));
952
+ state.surface = state.surface.map((node) => {
953
+ const indexes = offloaded.get(node.seq);
954
+ if (indexes === void 0) return node;
955
+ return {
956
+ ...node,
957
+ images: node.images.map((image, index) => indexes.has(index) ? {
958
+ ...image,
959
+ offloaded: true
960
+ } : image)
961
+ };
962
+ });
963
+ break;
964
+ }
965
+ case "request/header":
966
+ nextHeader = canonicalHeader(event.data.header);
967
+ break;
968
+ case "step/start":
969
+ if (state.stepStart !== void 0) throw new Error(`token meter: step/start at seq ${event.seq} arrived before turn ${state.stepStart.turn}/step ${state.stepStart.step} ended`);
970
+ nextStepStart = { ...event.data };
971
+ break;
972
+ case "step/end":
973
+ if (state.stepStart === void 0 || state.stepStart.turn !== event.data.turn || state.stepStart.step !== event.data.step) throw new Error(`token meter: step/end at seq ${event.seq} has no matching step/start event`);
974
+ nextStepStart = void 0;
975
+ break;
976
+ default: break;
977
+ }
978
+ const plan = isSurfaceEvent(event) ? planSurfaceTokens(state.surface, event) : void 0;
979
+ if (event.type === "assistant/message") {
980
+ const stepStart = state.stepStart;
981
+ if (stepStart === void 0 || stepStart.turn !== event.data.turn || stepStart.step !== event.data.step) throw new Error(`token meter: assistant/message at seq ${event.seq} has no matching step/start event`);
982
+ const eventTokens = plan.tokens;
983
+ if (event.data.usage !== void 0 && nextHeader !== void 0) nextAnchor = {
984
+ header: nextHeader,
985
+ nodes: [...state.surface],
986
+ assistantTokens: this._estimateProviderAssistant(event),
987
+ usage: event.data.usage
988
+ };
989
+ else nextAnchor = {
990
+ header: nextHeader,
991
+ nodes: [...state.surface],
992
+ assistantTokens: eventTokens,
993
+ usage: void 0
994
+ };
995
+ }
996
+ state.header = nextHeader;
997
+ state.stepStart = nextStepStart;
998
+ if (plan !== void 0) commitSurfaceTokens(state.surface, plan);
999
+ state.anchor = nextAnchor;
1000
+ }
1001
+ /**
1002
+ * Reassemble provider output from the message's exact embedded stream.
1003
+ */
1004
+ _estimateProviderAssistant(event) {
1005
+ const providerContent = assembleAssistantStream(event.data.stream).blocks();
1006
+ return providerContent.length === 0 ? 0 : estimateContent(providerContent) + 4;
1007
+ }
1008
+ };
1009
+ //#endregion
1010
+ export { TokenMeter, TokenMeter as default, deriveTurnTokenUsage };