@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/LICENSE +21 -0
- package/README.i18n.yaml +64 -0
- package/README.md +154 -2
- package/README.zh.md +155 -0
- package/lib/index.js +1010 -0
- package/lib/types/breakdown-projection.d.ts +98 -0
- package/lib/types/breakdown-projection.js +68 -0
- package/lib/types/client.d.ts +9 -0
- package/lib/types/client.js +7 -0
- package/lib/types/estimate.d.ts +50 -0
- package/lib/types/estimate.js +95 -0
- package/lib/types/index.d.ts +73 -0
- package/lib/types/index.js +260 -0
- package/lib/types/projection.d.ts +74 -0
- package/lib/types/projection.js +7 -0
- package/lib/types/route-pricing.d.ts +31 -0
- package/lib/types/route-pricing.js +56 -0
- package/lib/types/surface-fold.d.ts +68 -0
- package/lib/types/surface-fold.js +98 -0
- package/lib/types/surface-projection.d.ts +58 -0
- package/lib/types/surface-projection.js +68 -0
- package/lib/types/turn-usage.d.ts +33 -0
- package/lib/types/turn-usage.js +229 -0
- package/lib/types/types.d.ts +56 -0
- package/lib/types/types.js +7 -0
- package/lib/types/usage-projection.d.ts +223 -0
- package/lib/types/usage-projection.js +189 -0
- package/package.json +61 -3
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heuristic composition of the current retained surface, independent of route
|
|
3
|
+
* image pricing and provider usage. Positional entries preserve system-prompt
|
|
4
|
+
* classification across replacements without retaining historical messages.
|
|
5
|
+
*/
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { SessionSeq } from '@qilin-agent/session';
|
|
8
|
+
declare module '@qilin-agent/session-projection/types' {
|
|
9
|
+
interface SessionProjectionStateMap {
|
|
10
|
+
contextBreakdown: ContextBreakdownState;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Plain-JSON checkpoint: one compact entry per retained surface position. */
|
|
14
|
+
declare const contextBreakdownStateSchema: z.ZodObject<{
|
|
15
|
+
nodes: z.ZodArray<z.ZodObject<{
|
|
16
|
+
seq: z.ZodPipe<z.ZodNumber, z.ZodTransform<SessionSeq, number>>;
|
|
17
|
+
heuristicTokens: z.ZodNumber;
|
|
18
|
+
system: z.ZodBoolean;
|
|
19
|
+
}, z.core.$strict>>;
|
|
20
|
+
breakdown: z.ZodObject<{
|
|
21
|
+
systemTokens: z.ZodNumber;
|
|
22
|
+
toolsTokens: z.ZodNumber;
|
|
23
|
+
messageTokens: z.ZodNumber;
|
|
24
|
+
}, z.core.$strict>;
|
|
25
|
+
}, z.core.$strict>;
|
|
26
|
+
type ContextBreakdownState = z.infer<typeof contextBreakdownStateSchema>;
|
|
27
|
+
/**
|
|
28
|
+
* Context composition with the last nonempty surviving system in surface
|
|
29
|
+
* order classified as system tokens; all other visible prices are messages.
|
|
30
|
+
* Replacements use the measurement planner, not shadow-price claims. State
|
|
31
|
+
* and surface transitions cost O(current retained surface), not O(log length).
|
|
32
|
+
* Tools are priced from the latest request header. No route pricing applies.
|
|
33
|
+
*/
|
|
34
|
+
export declare const contextBreakdownProjectionDefinition: {
|
|
35
|
+
key: "contextBreakdown";
|
|
36
|
+
stateVersion: number;
|
|
37
|
+
stateSchema: z.ZodObject<{
|
|
38
|
+
nodes: z.ZodArray<z.ZodObject<{
|
|
39
|
+
seq: z.ZodPipe<z.ZodNumber, z.ZodTransform<SessionSeq, number>>;
|
|
40
|
+
heuristicTokens: z.ZodNumber;
|
|
41
|
+
system: z.ZodBoolean;
|
|
42
|
+
}, z.core.$strict>>;
|
|
43
|
+
breakdown: z.ZodObject<{
|
|
44
|
+
systemTokens: z.ZodNumber;
|
|
45
|
+
toolsTokens: z.ZodNumber;
|
|
46
|
+
messageTokens: z.ZodNumber;
|
|
47
|
+
}, z.core.$strict>;
|
|
48
|
+
}, z.core.$strict>;
|
|
49
|
+
init: () => ContextBreakdownState;
|
|
50
|
+
apply: (state: NoInfer<{
|
|
51
|
+
nodes: {
|
|
52
|
+
seq: SessionSeq;
|
|
53
|
+
heuristicTokens: number;
|
|
54
|
+
system: boolean;
|
|
55
|
+
}[];
|
|
56
|
+
breakdown: {
|
|
57
|
+
systemTokens: number;
|
|
58
|
+
toolsTokens: number;
|
|
59
|
+
messageTokens: number;
|
|
60
|
+
};
|
|
61
|
+
}>, event: import("@qilin-agent/session").SessionEvent) => {
|
|
62
|
+
nodes: {
|
|
63
|
+
seq: SessionSeq;
|
|
64
|
+
heuristicTokens: number;
|
|
65
|
+
system: boolean;
|
|
66
|
+
}[];
|
|
67
|
+
breakdown: {
|
|
68
|
+
systemTokens: number;
|
|
69
|
+
toolsTokens: number;
|
|
70
|
+
messageTokens: number;
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
wire: {
|
|
74
|
+
viewSchema: z.ZodObject<{
|
|
75
|
+
systemTokens: z.ZodNumber;
|
|
76
|
+
toolsTokens: z.ZodNumber;
|
|
77
|
+
messageTokens: z.ZodNumber;
|
|
78
|
+
}, z.core.$strict>;
|
|
79
|
+
view: (state: NoInfer<{
|
|
80
|
+
nodes: {
|
|
81
|
+
seq: SessionSeq;
|
|
82
|
+
heuristicTokens: number;
|
|
83
|
+
system: boolean;
|
|
84
|
+
}[];
|
|
85
|
+
breakdown: {
|
|
86
|
+
systemTokens: number;
|
|
87
|
+
toolsTokens: number;
|
|
88
|
+
messageTokens: number;
|
|
89
|
+
};
|
|
90
|
+
}>) => {
|
|
91
|
+
systemTokens: number;
|
|
92
|
+
toolsTokens: number;
|
|
93
|
+
messageTokens: number;
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
export {};
|
|
98
|
+
//# sourceMappingURL=breakdown-projection.d.ts.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heuristic composition of the current retained surface, independent of route
|
|
3
|
+
* image pricing and provider usage. Positional entries preserve system-prompt
|
|
4
|
+
* classification across replacements without retaining historical messages.
|
|
5
|
+
*/
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { canonicalHeader, isSurfaceEvent, SessionSeq } from '@qilin-agent/session';
|
|
8
|
+
import { estimateToolsTokens } from "./estimate.js";
|
|
9
|
+
import { commitSurfaceTokens, planSurfaceTokens } from "./surface-fold.js";
|
|
10
|
+
const tokenCount = z.number().int().nonnegative();
|
|
11
|
+
const sessionSeq = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(SessionSeq);
|
|
12
|
+
const breakdownSchema = z.object({
|
|
13
|
+
systemTokens: tokenCount,
|
|
14
|
+
toolsTokens: tokenCount,
|
|
15
|
+
messageTokens: tokenCount,
|
|
16
|
+
}).strict();
|
|
17
|
+
/** Plain-JSON checkpoint: one compact entry per retained surface position. */
|
|
18
|
+
const contextBreakdownStateSchema = z.object({
|
|
19
|
+
nodes: z.array(z.object({
|
|
20
|
+
seq: sessionSeq,
|
|
21
|
+
heuristicTokens: tokenCount,
|
|
22
|
+
system: z.boolean(),
|
|
23
|
+
}).strict()),
|
|
24
|
+
breakdown: breakdownSchema,
|
|
25
|
+
}).strict();
|
|
26
|
+
/**
|
|
27
|
+
* Context composition with the last nonempty surviving system in surface
|
|
28
|
+
* order classified as system tokens; all other visible prices are messages.
|
|
29
|
+
* Replacements use the measurement planner, not shadow-price claims. State
|
|
30
|
+
* and surface transitions cost O(current retained surface), not O(log length).
|
|
31
|
+
* Tools are priced from the latest request header. No route pricing applies.
|
|
32
|
+
*/
|
|
33
|
+
export const contextBreakdownProjectionDefinition = {
|
|
34
|
+
key: 'contextBreakdown',
|
|
35
|
+
stateVersion: 5,
|
|
36
|
+
stateSchema: contextBreakdownStateSchema,
|
|
37
|
+
init: () => ({
|
|
38
|
+
nodes: [],
|
|
39
|
+
breakdown: { systemTokens: 0, toolsTokens: 0, messageTokens: 0 },
|
|
40
|
+
}),
|
|
41
|
+
apply: (state, event) => {
|
|
42
|
+
if (event.type === 'request/header') {
|
|
43
|
+
const toolsTokens = estimateToolsTokens(canonicalHeader(event.data.header));
|
|
44
|
+
return toolsTokens === state.breakdown.toolsTokens
|
|
45
|
+
? state
|
|
46
|
+
: { ...state, breakdown: { ...state.breakdown, toolsTokens } };
|
|
47
|
+
}
|
|
48
|
+
if (!isSurfaceEvent(event))
|
|
49
|
+
return state;
|
|
50
|
+
const plan = planSurfaceTokens(state.nodes, event);
|
|
51
|
+
const nodes = [...state.nodes];
|
|
52
|
+
commitSurfaceTokens(nodes, {
|
|
53
|
+
...plan,
|
|
54
|
+
node: { seq: event.seq, heuristicTokens: plan.tokens, system: event.type === 'system/message' },
|
|
55
|
+
});
|
|
56
|
+
const systemTokens = nodes.findLast(node => node.system && node.heuristicTokens > 0)?.heuristicTokens ?? 0;
|
|
57
|
+
const messageTokens = state.breakdown.systemTokens + state.breakdown.messageTokens + plan.deltaTokens - systemTokens;
|
|
58
|
+
const breakdown = systemTokens === state.breakdown.systemTokens && messageTokens === state.breakdown.messageTokens
|
|
59
|
+
? state.breakdown
|
|
60
|
+
: { systemTokens, toolsTokens: state.breakdown.toolsTokens, messageTokens };
|
|
61
|
+
return { nodes, breakdown };
|
|
62
|
+
},
|
|
63
|
+
wire: {
|
|
64
|
+
viewSchema: breakdownSchema,
|
|
65
|
+
view: state => state.breakdown,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
//# sourceMappingURL=breakdown-projection.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-namespace projection of token-meter's browser-safe contracts and folds.
|
|
3
|
+
*
|
|
4
|
+
* @module @qilin-agent/token-meter/client
|
|
5
|
+
*/
|
|
6
|
+
export type * from './projection.ts';
|
|
7
|
+
export { deriveTurnTokenUsage } from './turn-usage.ts';
|
|
8
|
+
export type { TurnTokenUsage, TurnTokenUsageRoute } from './turn-usage.ts';
|
|
9
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed-density heuristic token pricing shared by the meter service and the
|
|
3
|
+
* pure context-breakdown projection, so both surfaces price identical content
|
|
4
|
+
* to identical numbers.
|
|
5
|
+
*
|
|
6
|
+
* @module @qilin-agent/token-meter/estimate
|
|
7
|
+
*/
|
|
8
|
+
import type { ContentBlock, Message } from '@qilin-agent/llm';
|
|
9
|
+
import type { EpochHeader } from '@qilin-agent/session';
|
|
10
|
+
/** Role-field framing overhead added to every priced message. */
|
|
11
|
+
export declare const ROLE_OVERHEAD = 4;
|
|
12
|
+
/**
|
|
13
|
+
* Structural JSON price of one block outside the typed pricing arms: the
|
|
14
|
+
* fixed heuristic for merge-extended blocks and for image references, whose
|
|
15
|
+
* request price is route-owned rather than fixed. Image offload marks do not
|
|
16
|
+
* change this reference-only heuristic; route pricing owns their placeholders.
|
|
17
|
+
* @param block - block to price without mutation.
|
|
18
|
+
* @returns heuristic tokens for the block's JSON structure.
|
|
19
|
+
*/
|
|
20
|
+
export declare function estimateStructuralBlock(block: ContentBlock): number;
|
|
21
|
+
/**
|
|
22
|
+
* Price content blocks recursively under the fixed density heuristic.
|
|
23
|
+
* @param blocks - content blocks to price without mutation.
|
|
24
|
+
* @returns heuristic tokens including per-block structural overhead.
|
|
25
|
+
*/
|
|
26
|
+
export declare function estimateContent(blocks: readonly ContentBlock[]): number;
|
|
27
|
+
/**
|
|
28
|
+
* Price the rendered system prompt: the `system/message` surface node's text.
|
|
29
|
+
* Adapters serialize the prompt as a plain string — a system-role message or
|
|
30
|
+
* the request's system field — not as a typed content block, so the price is
|
|
31
|
+
* text density plus role framing with no per-block overhead.
|
|
32
|
+
* @param message - system-role message to price without mutation.
|
|
33
|
+
* @returns heuristic system-prompt tokens; 0 for empty content ("no system prompt").
|
|
34
|
+
*/
|
|
35
|
+
export declare function estimateSystemMessage(message: Message): number;
|
|
36
|
+
/**
|
|
37
|
+
* Heuristically price one model-visible message.
|
|
38
|
+
* @param message - message to price without mutation.
|
|
39
|
+
* @returns content and role-framing tokens under the fixed heuristic; a
|
|
40
|
+
* system-role message prices as {@link estimateSystemMessage}.
|
|
41
|
+
*/
|
|
42
|
+
export declare function estimateMessage(message: Message): number;
|
|
43
|
+
/**
|
|
44
|
+
* Price the tool-schema part of a canonical request envelope — the envelope's
|
|
45
|
+
* only priced field, since the system prompt is a surface node.
|
|
46
|
+
* @param header - canonical envelope, or undefined before any request.
|
|
47
|
+
* @returns heuristic tool-schema tokens; 0 when absent or empty.
|
|
48
|
+
*/
|
|
49
|
+
export declare function estimateToolsTokens(header: EpochHeader | undefined): number;
|
|
50
|
+
//# sourceMappingURL=estimate.d.ts.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed-density heuristic token pricing shared by the meter service and the
|
|
3
|
+
* pure context-breakdown projection, so both surfaces price identical content
|
|
4
|
+
* to identical numbers.
|
|
5
|
+
*
|
|
6
|
+
* @module @qilin-agent/token-meter/estimate
|
|
7
|
+
*/
|
|
8
|
+
/** Fixed text-density estimate used until exact tokenization is needed. */
|
|
9
|
+
const CHARS_PER_TOKEN = 4;
|
|
10
|
+
/** Per-block structural overhead for JSON framing and type tags. */
|
|
11
|
+
const BLOCK_OVERHEAD = 4;
|
|
12
|
+
/** Role-field framing overhead added to every priced message. */
|
|
13
|
+
export const ROLE_OVERHEAD = 4;
|
|
14
|
+
/**
|
|
15
|
+
* Structural JSON price of one block outside the typed pricing arms: the
|
|
16
|
+
* fixed heuristic for merge-extended blocks and for image references, whose
|
|
17
|
+
* request price is route-owned rather than fixed. Image offload marks do not
|
|
18
|
+
* change this reference-only heuristic; route pricing owns their placeholders.
|
|
19
|
+
* @param block - block to price without mutation.
|
|
20
|
+
* @returns heuristic tokens for the block's JSON structure.
|
|
21
|
+
*/
|
|
22
|
+
export function estimateStructuralBlock(block) {
|
|
23
|
+
if (block.type === 'image') {
|
|
24
|
+
const { offloaded: _offloaded, ...reference } = block;
|
|
25
|
+
return BLOCK_OVERHEAD + Math.ceil(JSON.stringify(reference).length / CHARS_PER_TOKEN);
|
|
26
|
+
}
|
|
27
|
+
return BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Price content blocks recursively under the fixed density heuristic.
|
|
31
|
+
* @param blocks - content blocks to price without mutation.
|
|
32
|
+
* @returns heuristic tokens including per-block structural overhead.
|
|
33
|
+
*/
|
|
34
|
+
export function estimateContent(blocks) {
|
|
35
|
+
let tokens = 0;
|
|
36
|
+
for (const block of blocks) {
|
|
37
|
+
switch (block.type) {
|
|
38
|
+
case 'text':
|
|
39
|
+
case 'reasoning':
|
|
40
|
+
tokens += Math.ceil(block.text.length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
|
|
41
|
+
break;
|
|
42
|
+
case 'tool-call':
|
|
43
|
+
tokens += Math.ceil(block.name.length / CHARS_PER_TOKEN)
|
|
44
|
+
+ Math.ceil(block.arguments.length / CHARS_PER_TOKEN)
|
|
45
|
+
+ BLOCK_OVERHEAD;
|
|
46
|
+
break;
|
|
47
|
+
default:
|
|
48
|
+
// ContentBlockMap is merge-extensible; unknown blocks (and image
|
|
49
|
+
// references, whose request price is route-owned) retain a
|
|
50
|
+
// conservative structural JSON price under the fixed heuristic.
|
|
51
|
+
tokens += estimateStructuralBlock(block);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return tokens;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Price the rendered system prompt: the `system/message` surface node's text.
|
|
58
|
+
* Adapters serialize the prompt as a plain string — a system-role message or
|
|
59
|
+
* the request's system field — not as a typed content block, so the price is
|
|
60
|
+
* text density plus role framing with no per-block overhead.
|
|
61
|
+
* @param message - system-role message to price without mutation.
|
|
62
|
+
* @returns heuristic system-prompt tokens; 0 for empty content ("no system prompt").
|
|
63
|
+
*/
|
|
64
|
+
export function estimateSystemMessage(message) {
|
|
65
|
+
if (message.content.length === 0)
|
|
66
|
+
return 0;
|
|
67
|
+
let characters = 0;
|
|
68
|
+
for (const block of message.content) {
|
|
69
|
+
characters += block.type === 'text' ? block.text.length : JSON.stringify(block).length;
|
|
70
|
+
}
|
|
71
|
+
return Math.ceil(characters / CHARS_PER_TOKEN) + ROLE_OVERHEAD;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Heuristically price one model-visible message.
|
|
75
|
+
* @param message - message to price without mutation.
|
|
76
|
+
* @returns content and role-framing tokens under the fixed heuristic; a
|
|
77
|
+
* system-role message prices as {@link estimateSystemMessage}.
|
|
78
|
+
*/
|
|
79
|
+
export function estimateMessage(message) {
|
|
80
|
+
if (message.role === 'system')
|
|
81
|
+
return estimateSystemMessage(message);
|
|
82
|
+
return estimateContent(message.content) + ROLE_OVERHEAD;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Price the tool-schema part of a canonical request envelope — the envelope's
|
|
86
|
+
* only priced field, since the system prompt is a surface node.
|
|
87
|
+
* @param header - canonical envelope, or undefined before any request.
|
|
88
|
+
* @returns heuristic tool-schema tokens; 0 when absent or empty.
|
|
89
|
+
*/
|
|
90
|
+
export function estimateToolsTokens(header) {
|
|
91
|
+
if (header?.tools === undefined || header.tools.length === 0)
|
|
92
|
+
return 0;
|
|
93
|
+
return Math.ceil(JSON.stringify(header.tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=estimate.js.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single replay-aware token-meter service for request and surface pressure.
|
|
3
|
+
*
|
|
4
|
+
* @module @qilin-agent/token-meter
|
|
5
|
+
*/
|
|
6
|
+
import { Context, Service } from '@qilin-agent/kylin';
|
|
7
|
+
import z from '@qilin-agent/schemastery';
|
|
8
|
+
import type { Message } from '@qilin-agent/llm';
|
|
9
|
+
import type { EpochHeader, Session } from '@qilin-agent/session';
|
|
10
|
+
import type { TokenMeasurement, TokenMeterConfig } from './types.ts';
|
|
11
|
+
export type * from './types.ts';
|
|
12
|
+
export { deriveTurnTokenUsage } from './turn-usage.ts';
|
|
13
|
+
export type * from './usage-projection.ts';
|
|
14
|
+
export type * from './breakdown-projection.ts';
|
|
15
|
+
declare module '@qilin-agent/kylin' {
|
|
16
|
+
interface Context {
|
|
17
|
+
tokenMeter: TokenMeter;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Replay owner for one service-wide estimator and isolated per-session folds. */
|
|
21
|
+
export declare class TokenMeter extends Service {
|
|
22
|
+
static Config: z<TokenMeterConfig>;
|
|
23
|
+
static inject: string[];
|
|
24
|
+
private readonly states;
|
|
25
|
+
constructor(ctx: Context, config?: TokenMeterConfig);
|
|
26
|
+
/**
|
|
27
|
+
* Measure current request pressure and surface through the durable tail.
|
|
28
|
+
*
|
|
29
|
+
* The effective envelope's routed provider/model selects the request-image
|
|
30
|
+
* pricing every node is priced under: a route whose adapter declares image
|
|
31
|
+
* pricing charges each retained image its visual tokens plus its
|
|
32
|
+
* model-visible text, while other routes keep the fixed heuristic. Provider
|
|
33
|
+
* usage is reused only when the latest successful call's canonical request
|
|
34
|
+
* envelope matches `requestHeader` and its total is no lower than that
|
|
35
|
+
* call's full route-priced anchor; otherwise the complete envelope and
|
|
36
|
+
* surface are repriced. The anchor includes all surface nodes immediately
|
|
37
|
+
* before the assistant message, including inputs admitted after step/start.
|
|
38
|
+
*
|
|
39
|
+
* `requestHeader` replaces the latest logged envelope for pressure and node
|
|
40
|
+
* pricing; the node set always describes the current session surface. Every
|
|
41
|
+
* call clones those positional nodes, so measurement is O(surface).
|
|
42
|
+
*
|
|
43
|
+
* @param session - session to replay through its current durable tail.
|
|
44
|
+
* @param requestHeader - optional effective request envelope replacing the latest logged header.
|
|
45
|
+
* @returns a detached deeply immutable pressure and surface measurement.
|
|
46
|
+
*/
|
|
47
|
+
measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement;
|
|
48
|
+
/** Resolve the routed model's image pricing, when the llm service and route declare one. */
|
|
49
|
+
private _routeImagePricing;
|
|
50
|
+
/** Resolve request-time file projection when an LLM service is mounted. */
|
|
51
|
+
private _fileRequestText;
|
|
52
|
+
/**
|
|
53
|
+
* Heuristically price one model-visible message (instance face of the pure
|
|
54
|
+
* `estimateMessage` export from `estimate.ts`).
|
|
55
|
+
* @param message - message to price without mutation.
|
|
56
|
+
* @returns content and role-framing tokens under the fixed service heuristic.
|
|
57
|
+
*/
|
|
58
|
+
estimateMessage(message: Message): number;
|
|
59
|
+
/** Catch one session's fold up to the current durable tail. */
|
|
60
|
+
private _sync;
|
|
61
|
+
/**
|
|
62
|
+
* Run every fallible step — surface plan and anchor validation — before
|
|
63
|
+
* mutating replay state, so a malformed event remains unread on every
|
|
64
|
+
* retry instead of half-applying.
|
|
65
|
+
*/
|
|
66
|
+
private _foldEvent;
|
|
67
|
+
/**
|
|
68
|
+
* Reassemble provider output from the message's exact embedded stream.
|
|
69
|
+
*/
|
|
70
|
+
private _estimateProviderAssistant;
|
|
71
|
+
}
|
|
72
|
+
export default TokenMeter;
|
|
73
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single replay-aware token-meter service for request and surface pressure.
|
|
3
|
+
*
|
|
4
|
+
* @module @qilin-agent/token-meter
|
|
5
|
+
*/
|
|
6
|
+
import { Service } from '@qilin-agent/kylin';
|
|
7
|
+
import z from '@qilin-agent/schemastery';
|
|
8
|
+
import { assembleAssistantStream } from '@qilin-agent/llm';
|
|
9
|
+
import { deepFreeze } from '@qilin-agent/util-values';
|
|
10
|
+
import { canonicalHeader, headerEquals, isSurfaceEvent, SessionLogOffset, SessionSeq, } from '@qilin-agent/session';
|
|
11
|
+
import { contextBreakdownProjectionDefinition } from "./breakdown-projection.js";
|
|
12
|
+
import { contextPressureProjectionDefinition, tokenUsageProjectionDefinition } from "./usage-projection.js";
|
|
13
|
+
import { estimateContent, estimateMessage, estimateToolsTokens, ROLE_OVERHEAD } from "./estimate.js";
|
|
14
|
+
import { commitSurfaceTokens, planSurfaceTokens } from "./surface-fold.js";
|
|
15
|
+
import { priceSurface } from "./route-pricing.js";
|
|
16
|
+
// The exact per-Turn fold, for consumers that account for a Turn outside the
|
|
17
|
+
// browser transcript; a deployment's usage reporter is the current one.
|
|
18
|
+
export { deriveTurnTokenUsage } from "./turn-usage.js";
|
|
19
|
+
/** Sum disjoint provider usage buckets without double-counting reasoning output. */
|
|
20
|
+
function usageTokens(usage) {
|
|
21
|
+
return usage.inputTokens
|
|
22
|
+
+ (usage.cacheReadTokens ?? 0)
|
|
23
|
+
+ (usage.cacheWriteTokens ?? 0)
|
|
24
|
+
+ usage.outputTokens;
|
|
25
|
+
}
|
|
26
|
+
/** Compare optional envelopes so a headerless estimate can track later surface deltas. */
|
|
27
|
+
function optionalHeaderEquals(left, right) {
|
|
28
|
+
if (left === undefined || right === undefined)
|
|
29
|
+
return left === right;
|
|
30
|
+
return headerEquals(left, right);
|
|
31
|
+
}
|
|
32
|
+
/** Reject stale or misspelled keys before defaults can hide them. */
|
|
33
|
+
function validateConfigKeys(config) {
|
|
34
|
+
for (const key of Object.keys(config)) {
|
|
35
|
+
throw new Error(`TokenMeterConfig: unknown key "${key}" (no settings are supported)`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Replay owner for one service-wide estimator and isolated per-session folds. */
|
|
39
|
+
export class TokenMeter extends Service {
|
|
40
|
+
// Schemastery preserves untrusted loader keys on an empty object schema;
|
|
41
|
+
// the public type excludes settings while validateConfigKeys rejects them.
|
|
42
|
+
static Config = z.object({});
|
|
43
|
+
static inject = ['sessionProjections'];
|
|
44
|
+
states = new WeakMap();
|
|
45
|
+
constructor(ctx, config = {}) {
|
|
46
|
+
super(ctx, 'tokenMeter');
|
|
47
|
+
validateConfigKeys(config);
|
|
48
|
+
ctx.sessionProjections.register(tokenUsageProjectionDefinition);
|
|
49
|
+
ctx.sessionProjections.register(contextPressureProjectionDefinition);
|
|
50
|
+
ctx.sessionProjections.register(contextBreakdownProjectionDefinition);
|
|
51
|
+
// Readers catch up independently, while eager observation bounds ordinary
|
|
52
|
+
// read latency without creating state for sessions no consumer has read.
|
|
53
|
+
ctx.on('session/event', (session) => {
|
|
54
|
+
if (this.states.has(session))
|
|
55
|
+
this._sync(session);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Measure current request pressure and surface through the durable tail.
|
|
60
|
+
*
|
|
61
|
+
* The effective envelope's routed provider/model selects the request-image
|
|
62
|
+
* pricing every node is priced under: a route whose adapter declares image
|
|
63
|
+
* pricing charges each retained image its visual tokens plus its
|
|
64
|
+
* model-visible text, while other routes keep the fixed heuristic. Provider
|
|
65
|
+
* usage is reused only when the latest successful call's canonical request
|
|
66
|
+
* envelope matches `requestHeader` and its total is no lower than that
|
|
67
|
+
* call's full route-priced anchor; otherwise the complete envelope and
|
|
68
|
+
* surface are repriced. The anchor includes all surface nodes immediately
|
|
69
|
+
* before the assistant message, including inputs admitted after step/start.
|
|
70
|
+
*
|
|
71
|
+
* `requestHeader` replaces the latest logged envelope for pressure and node
|
|
72
|
+
* pricing; the node set always describes the current session surface. Every
|
|
73
|
+
* call clones those positional nodes, so measurement is O(surface).
|
|
74
|
+
*
|
|
75
|
+
* @param session - session to replay through its current durable tail.
|
|
76
|
+
* @param requestHeader - optional effective request envelope replacing the latest logged header.
|
|
77
|
+
* @returns a detached deeply immutable pressure and surface measurement.
|
|
78
|
+
*/
|
|
79
|
+
measure(session, requestHeader) {
|
|
80
|
+
const state = this._sync(session);
|
|
81
|
+
const header = requestHeader === undefined
|
|
82
|
+
? state.header
|
|
83
|
+
: canonicalHeader(requestHeader);
|
|
84
|
+
const pricing = this._routeImagePricing(header);
|
|
85
|
+
const fileText = this._fileRequestText();
|
|
86
|
+
const surface = priceSurface(state.surface, pricing, fileText);
|
|
87
|
+
const anchor = state.anchor;
|
|
88
|
+
let baseline;
|
|
89
|
+
let surfaceDeltaTokens;
|
|
90
|
+
if (anchor !== undefined && optionalHeaderEquals(anchor.header, header)) {
|
|
91
|
+
// Matching headers share one route, so the anchored snapshot reprices
|
|
92
|
+
// under the same pricing as the current surface and the signed delta
|
|
93
|
+
// compares like with like.
|
|
94
|
+
const anchorSurfaceTokens = priceSurface(anchor.nodes, pricing, fileText).surfaceTokens
|
|
95
|
+
+ anchor.assistantTokens;
|
|
96
|
+
const estimatedAnchorTokens = estimateToolsTokens(header) + anchorSurfaceTokens;
|
|
97
|
+
const usage = anchor.usage;
|
|
98
|
+
// Signed heuristic deltas remain conservative only from an anchor
|
|
99
|
+
// that is at least as large as the matching full heuristic price.
|
|
100
|
+
baseline = usage !== undefined && usageTokens(usage) >= estimatedAnchorTokens
|
|
101
|
+
? { kind: 'usage', tokens: usageTokens(usage), usage }
|
|
102
|
+
: { kind: 'estimated', tokens: estimatedAnchorTokens };
|
|
103
|
+
surfaceDeltaTokens = surface.surfaceTokens - anchorSurfaceTokens;
|
|
104
|
+
}
|
|
105
|
+
else if (header === undefined && surface.surfaceTokens === 0) {
|
|
106
|
+
baseline = { kind: 'none', tokens: 0 };
|
|
107
|
+
surfaceDeltaTokens = 0;
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
baseline = {
|
|
111
|
+
kind: 'estimated',
|
|
112
|
+
tokens: estimateToolsTokens(header) + surface.surfaceTokens,
|
|
113
|
+
};
|
|
114
|
+
surfaceDeltaTokens = 0;
|
|
115
|
+
}
|
|
116
|
+
return deepFreeze(structuredClone({
|
|
117
|
+
logRevision: state.consumedEvents,
|
|
118
|
+
baseline,
|
|
119
|
+
surfaceDeltaTokens,
|
|
120
|
+
totalTokens: Math.max(0, baseline.tokens + surfaceDeltaTokens),
|
|
121
|
+
surfaceTokens: surface.surfaceTokens,
|
|
122
|
+
nodes: surface.nodes,
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
/** Resolve the routed model's image pricing, when the llm service and route declare one. */
|
|
126
|
+
_routeImagePricing(header) {
|
|
127
|
+
const config = header?.config;
|
|
128
|
+
if (config === undefined)
|
|
129
|
+
return undefined;
|
|
130
|
+
return this.ctx.get('llm')?.imageRequestPricing(config.provider, config.model);
|
|
131
|
+
}
|
|
132
|
+
/** Resolve request-time file projection when an LLM service is mounted. */
|
|
133
|
+
_fileRequestText() {
|
|
134
|
+
const llm = this.ctx.get('llm');
|
|
135
|
+
return llm === undefined ? undefined : ref => llm.fileRequestText(ref);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Heuristically price one model-visible message (instance face of the pure
|
|
139
|
+
* `estimateMessage` export from `estimate.ts`).
|
|
140
|
+
* @param message - message to price without mutation.
|
|
141
|
+
* @returns content and role-framing tokens under the fixed service heuristic.
|
|
142
|
+
*/
|
|
143
|
+
estimateMessage(message) {
|
|
144
|
+
return estimateMessage(message);
|
|
145
|
+
}
|
|
146
|
+
/** Catch one session's fold up to the current durable tail. */
|
|
147
|
+
_sync(session) {
|
|
148
|
+
let state = this.states.get(session);
|
|
149
|
+
if (state === undefined) {
|
|
150
|
+
state = {
|
|
151
|
+
consumedEvents: SessionLogOffset(0),
|
|
152
|
+
header: undefined,
|
|
153
|
+
surface: [],
|
|
154
|
+
stepStart: undefined,
|
|
155
|
+
anchor: undefined,
|
|
156
|
+
};
|
|
157
|
+
this.states.set(session, state);
|
|
158
|
+
}
|
|
159
|
+
while (state.consumedEvents < session.seq) {
|
|
160
|
+
// Contiguous session seqs index the durable log; existing Session history read, migration deferred.
|
|
161
|
+
// oxlint-disable-next-line typescript/no-non-null-assertion, typescript/no-deprecated
|
|
162
|
+
const event = session.eventAt(SessionSeq(state.consumedEvents));
|
|
163
|
+
this._foldEvent(state, event);
|
|
164
|
+
state.consumedEvents = SessionLogOffset(state.consumedEvents + 1);
|
|
165
|
+
}
|
|
166
|
+
return state;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Run every fallible step — surface plan and anchor validation — before
|
|
170
|
+
* mutating replay state, so a malformed event remains unread on every
|
|
171
|
+
* retry instead of half-applying.
|
|
172
|
+
*/
|
|
173
|
+
_foldEvent(state, event) {
|
|
174
|
+
let nextHeader = state.header;
|
|
175
|
+
let nextStepStart = state.stepStart;
|
|
176
|
+
let nextAnchor = state.anchor;
|
|
177
|
+
switch (event.type) {
|
|
178
|
+
case 'image/offload': {
|
|
179
|
+
const offloaded = new Map(event.data.targets.map(target => [target.seq, new Set(target.imageIndexes)]));
|
|
180
|
+
state.surface = state.surface.map((node) => {
|
|
181
|
+
const indexes = offloaded.get(node.seq);
|
|
182
|
+
if (indexes === undefined)
|
|
183
|
+
return node;
|
|
184
|
+
return {
|
|
185
|
+
...node,
|
|
186
|
+
images: node.images.map((image, index) => (indexes.has(index) ? { ...image, offloaded: true } : image)),
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
case 'request/header':
|
|
192
|
+
nextHeader = canonicalHeader(event.data.header);
|
|
193
|
+
break;
|
|
194
|
+
case 'step/start':
|
|
195
|
+
if (state.stepStart !== undefined) {
|
|
196
|
+
throw new Error(`token meter: step/start at seq ${event.seq} arrived before turn ${state.stepStart.turn}/step ${state.stepStart.step} ended`);
|
|
197
|
+
}
|
|
198
|
+
nextStepStart = { ...event.data };
|
|
199
|
+
break;
|
|
200
|
+
case 'step/end':
|
|
201
|
+
if (state.stepStart === undefined
|
|
202
|
+
|| state.stepStart.turn !== event.data.turn
|
|
203
|
+
|| state.stepStart.step !== event.data.step) {
|
|
204
|
+
throw new Error(`token meter: step/end at seq ${event.seq} has no matching step/start event`);
|
|
205
|
+
}
|
|
206
|
+
nextStepStart = undefined;
|
|
207
|
+
break;
|
|
208
|
+
default:
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
const plan = isSurfaceEvent(event)
|
|
212
|
+
? planSurfaceTokens(state.surface, event)
|
|
213
|
+
: undefined;
|
|
214
|
+
if (event.type === 'assistant/message') {
|
|
215
|
+
const stepStart = state.stepStart;
|
|
216
|
+
if (stepStart === undefined
|
|
217
|
+
|| stepStart.turn !== event.data.turn
|
|
218
|
+
|| stepStart.step !== event.data.step) {
|
|
219
|
+
throw new Error(`token meter: assistant/message at seq ${event.seq} has no matching step/start event`);
|
|
220
|
+
}
|
|
221
|
+
// assistant/message is surface-mandatory at every append/seed boundary.
|
|
222
|
+
// oxlint-disable-next-line typescript/no-non-null-assertion
|
|
223
|
+
const eventTokens = plan.tokens;
|
|
224
|
+
// The loop admits prompts and user messages after step/start; retries may
|
|
225
|
+
// replace them before succeeding. Only the pre-assistant surface is priced
|
|
226
|
+
// by this call. Provider output stays separate from durable output rewrites.
|
|
227
|
+
if (event.data.usage !== undefined && nextHeader !== undefined) {
|
|
228
|
+
nextAnchor = {
|
|
229
|
+
header: nextHeader,
|
|
230
|
+
nodes: [...state.surface],
|
|
231
|
+
assistantTokens: this._estimateProviderAssistant(event),
|
|
232
|
+
usage: event.data.usage,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
nextAnchor = {
|
|
237
|
+
header: nextHeader,
|
|
238
|
+
nodes: [...state.surface],
|
|
239
|
+
assistantTokens: eventTokens,
|
|
240
|
+
usage: undefined,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
state.header = nextHeader;
|
|
245
|
+
state.stepStart = nextStepStart;
|
|
246
|
+
if (plan !== undefined) {
|
|
247
|
+
commitSurfaceTokens(state.surface, plan);
|
|
248
|
+
}
|
|
249
|
+
state.anchor = nextAnchor;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Reassemble provider output from the message's exact embedded stream.
|
|
253
|
+
*/
|
|
254
|
+
_estimateProviderAssistant(event) {
|
|
255
|
+
const providerContent = assembleAssistantStream(event.data.stream).blocks();
|
|
256
|
+
return providerContent.length === 0 ? 0 : estimateContent(providerContent) + ROLE_OVERHEAD;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
export default TokenMeter;
|
|
260
|
+
//# sourceMappingURL=index.js.map
|