fix(token-meter): close projected usage review gaps

This commit is contained in:
Hypatia May
2026-07-30 17:22:15 +08:00
parent e23cd8e406
commit 6d58953f30
43 changed files with 280 additions and 183 deletions
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/token-meter/README.md
README.md: b9bf1dfa253e424e5ec35cd3e7bf0f52af579077
README.zh.md: c97fc0b87364dfe9ca46139f0ec82519e191b772
README.md: 701893b342f9a93a75bec175634b1054f3d17151
README.zh.md: a5844e8788422bba669632ed587fb87e1e2a1e58
+2 -1
View File
@@ -27,7 +27,7 @@ When the composition provides `ctx.sessionProjections`, token-meter registers tw
`tokenUsage` carries the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. Usage chunks are counted even when a request later fails; a final assistant-message usage for the same `(turn, step)` replaces that sample instead of double-counting it. Reasoning remains an output subdivision. The single last-sample slot relies on a session-log ordering property: once a later step reports usage, a legal log never reports usage for an earlier step again.
`contextPressure` carries `pressureTokens` — the newest provider-reported prompt size, summing uncached input plus cache reads and writes — and the optional `contextWindow` from the newest `request/context` record. Output is excluded, so the numerator holds still while a turn streams and steps forward when the next request reports its usage.
`contextPressure` carries optional `pressureTokens` — the newest provider-reported prompt size, summing uncached input plus cache reads and writes — and optional `contextWindow` from the newest `request/context` record. Pressure stays absent until a provider reports usage; capacity stays absent for a route whose adapter advertises none. Output is excluded, so the numerator holds still while a turn streams and steps forward when the next request reports its usage.
Both units use the standard projection baseline, live frame, higher-seq-wins store, and JSON checkpoint paths. Unloading token-meter removes both keys. A headless or TUI composition without the projection seam keeps the measurement service's existing behavior.
@@ -62,3 +62,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
- **Every measurement clones the current surface** — coherent immutable snapshots make reads O(surface), including below-threshold pressure checks.
- **Provider usage is only reusable for an identical canonical envelope** — prompt, prefix, tools, provider, model, or call-config changes deliberately fall back to full heuristic estimation.
- **Legacy provenance is conservative** — assistant messages without `sourceEventSeqs` cannot distinguish provider output from listener rewrites, so the fold avoids claiming a known empty or exact chunk stream.
- **The TUI and browser fixture retain parallel folds** — `tokenUsage` owns durable session-projection semantics; the TUI keeps its live per-step map because its composition does not mount the generic projection seam, while the browser fixture mirrors the unit for standalone demo data.
+2 -1
View File
@@ -27,7 +27,7 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成
`tokenUsage` 携带完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 和 `cacheWriteTokens`。即使请求随后失败,用量分片仍会计入;同一 `(turn, step)` 的最终 assistant 消息用量会替换该样本,而不是重复计数。推理仍是输出的一个细分项。只保留单个最新样本,依赖的是会话日志的一条顺序性质:一旦某个更晚的步骤报告了用量,合法日志就绝不会再为更早的步骤报告用量。
`contextPressure` 携带 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和),以及来自最新一条 `request/context` 记录的可选 `contextWindow`。输出不计入其中,因此轮次流式输出期间分子保持不动,等到下一个请求报告用量时才前进。
`contextPressure` 携带可选的 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和),以及来自最新一条 `request/context` 记录的可选 `contextWindow`。提供方报告用量前压力保持缺失;路由适配器未公布容量时容量也保持缺失。输出不计入其中,因此轮次流式输出期间分子保持不动,等到下一个请求报告用量时才前进。
两个单元都使用标准的投影基线、实时帧、seq 高者胜值仓和 JSON 检查点路径。卸载 token-meter 会移除这两个键。不带投影 seam 的 headless 或 TUI 组合会保留测量服务的既有行为。
@@ -62,3 +62,4 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成
- **每次测量都会克隆当前表层**:一致且不可变的快照使读取成为 O(surface),包括低于阈值的压力检查。
- **提供方用量只能为完全相同的规范 envelope 复用**:提示词、前缀、工具、提供方、模型或调用配置变更都会有意回退到完整启发式估算。
- **遗留溯源采取保守策略**:没有 `sourceEventSeqs` 的 assistant 消息无法区分提供方输出与 listener 改写,因此 fold 不会声称已知空流或精确分片流。
- **TUI 与浏览器 fixture 仍保留并行 fold**:`tokenUsage` 拥有持久会话投影语义;TUI 的组合未挂载通用投影 seam,因此继续维护实时的逐步骤 map,而浏览器 fixture 会为独立 demo 数据镜像该单元。
+4 -4
View File
@@ -20,8 +20,8 @@ export interface TokenUsageProjection {
/**
* Approximate context occupancy for a status display.
*
* The two fields are deliberately NOT one atomic request observation:
* `pressureTokens` is the newest provider-reported prompt size in the log,
* The two fields, when present, are deliberately NOT one atomic request
* observation: `pressureTokens` is the newest provider-reported prompt size,
* `contextWindow` the newest recorded route capacity. Switching models can
* therefore pair a fresh capacity with the previous route's pressure until the
* next request reports usage. This is an intentional trade — the value is a
@@ -33,9 +33,9 @@ export interface ContextPressureProjection {
/**
* Provider-reported prompt size of the most recent request: uncached input
* plus cache reads and writes. Response output is excluded, so this does not
* grow as the current turn streams.
* grow as the current turn streams. Absent until a provider reports usage.
*/
pressureTokens: number
pressureTokens?: number
/** Newest recorded route capacity; absent when no adapter advertised one. */
contextWindow?: number
}
@@ -56,10 +56,10 @@ const projectionSchema = z.object({
cacheWriteTokens: z.number().int().nonnegative(),
}).strict()
// Cast for the optional capacity: under exactOptionalPropertyTypes zod infers
// `number | undefined` where the interface declares an absent-or-number field.
// Cast for the optional values: under exactOptionalPropertyTypes zod infers
// `number | undefined` where the interface declares absent-or-number fields.
const pressureSchema = z.object({
pressureTokens: z.number().int().nonnegative(),
pressureTokens: z.number().int().nonnegative().optional(),
contextWindow: z.number().int().positive().optional(),
}).strict() as unknown as z.ZodType<ContextPressureProjection>
@@ -128,12 +128,14 @@ export const contextPressureProjectionDefinition:
ProjectionDefinition<'contextPressure', ContextPressureProjection> = {
key: 'contextPressure',
schema: pressureSchema,
init: () => ({ pressureTokens: 0 }),
init: () => ({}),
apply: (state, event) => {
if (event.type === 'request/context') {
return event.data.contextWindow === state.contextWindow
? state
: { ...state, contextWindow: event.data.contextWindow }
const contextWindow = event.data.contextWindow
if (contextWindow === state.contextWindow) return state
if (contextWindow !== undefined) return { ...state, contextWindow }
const { contextWindow: _removed, ...withoutContextWindow } = state
return withoutContextWindow
}
const usage = event.type === 'assistant/chunk' && event.data.chunk.type === 'usage'
? event.data.chunk.usage
@@ -147,5 +149,5 @@ ProjectionDefinition<'contextPressure', ContextPressureProjection> = {
: { ...state, pressureTokens }
},
view: state => state,
stateVersion: 1,
stateVersion: 2,
}
@@ -227,14 +227,25 @@ const pressure = (ctx: Context, session: Session): ContextPressureProjection =>
return value
}
function recordContext(session: Session, model: string, contextWindow: number): void {
session.append('request/context', { provider: 'mock', model, contextWindow })
function recordContext(session: Session, model: string, contextWindow?: number): void {
session.append('request/context', {
provider: 'mock',
model,
...contextWindow === undefined ? {} : { contextWindow },
})
}
describe('contextPressure session projection', () => {
it('serves zero pressure and no capacity for an empty log', async () => {
it('serves no pressure or capacity for an empty log', async () => {
const { ctx, session } = await harness()
expect(pressure(ctx, session)).toEqual({ pressureTokens: 0 })
expect(pressure(ctx, session)).toEqual({})
})
it('does not synthesize zero pressure before a provider usage sample', async () => {
const { ctx, session } = await harness()
startStep(session, 1, 1)
recordContext(session, 'small', 64_000)
expect(pressure(ctx, session)).toEqual({ contextWindow: 64_000 })
})
it('sums prompt-side buckets and excludes response output', async () => {
@@ -271,6 +282,15 @@ describe('contextPressure session projection', () => {
expect(pressure(ctx, session)).toEqual({ pressureTokens: 100, contextWindow: 256_000 })
})
it('removes an older capacity when the newest route advertises none', async () => {
const { ctx, session } = await harness()
startStep(session, 1, 1)
recordContext(session, 'small', 64_000)
usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1)
recordContext(session, 'unknown')
expect(pressure(ctx, session)).toEqual({ pressureTokens: 100 })
})
it('pushes no change for unrelated events or a restated capacity', async () => {
// The registry gates its change feed on Object.is, so a unit that rebuilt
// state for an event it does not care about would push phantom updates.
@@ -299,6 +319,7 @@ describe('contextPressure session projection', () => {
const checkpoint = JSON.parse(JSON.stringify(
ctx.sessionProjections.checkpoint(session),
)) as ReturnType<typeof ctx.sessionProjections.checkpoint>
expect(checkpoint.contextPressure?.ver).toBe(2)
await meterFiber.dispose()
expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('contextPressure')