@qilin-agent/token-meter 0.0.0-stage → 3.1.3

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