feat(token-meter): project durable token usage

This commit is contained in:
Hypatia May
2026-07-29 14:35:24 +08:00
parent 741b760557
commit e37cb23336
9 changed files with 383 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
/**
* Client-namespace projection of token-meter's browser-safe types.
*
* @module @deepseek-ai/dsh-token-meter/client
*/
export type * from './projection.ts'

View File

@@ -10,12 +10,15 @@ import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
import type { EpochHeader, Session, SessionEvent, SurfaceEvent } from '@deepseek-ai/dsh-session'
import { canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session'
// Type-only: resolves the optional projection registry Context seam.
import type {} from '@deepseek-ai/dsh-session-projection'
import type {
TokenMeasurement,
TokenMeasurementBaseline,
TokenMeterConfig,
TokenSurfaceNode,
} from './types.ts'
import { tokenUsageProjectionDefinition } from './usage-projection.ts'
export type * from './types.ts'
@@ -90,6 +93,12 @@ export class TokenMeterService extends Service {
super(ctx, 'tokenMeter')
validateConfigKeys(config)
// Projection registration is an optional child: headless and TUI
// compositions without the generic registry keep the meter's old shape.
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register(tokenUsageProjectionDefinition)
})
// Readers catch up independently, while eager observation bounds ordinary
// read latency without creating state for sessions no consumer has read.
ctx.on('session/event', (session) => {

View File

@@ -0,0 +1,25 @@
/**
* Pure client-safe token-usage projection vocabulary.
*
* @module @deepseek-ai/dsh-token-meter/projection
*/
/**
* Durable cumulative provider usage for a complete session log.
*
* The four buckets are disjoint. In particular, reasoning tokens are already
* included in `outputTokens` and are not accumulated again.
*/
export interface TokenUsageProjection {
uncachedInputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
/** Provider-reported usage accumulated across the complete durable log. */
tokenUsage: TokenUsageProjection
}
}

View File

@@ -6,6 +6,8 @@
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
export type { TokenUsageProjection } from './projection.ts'
/** Token-meter plugin configuration; the fixed estimator has no settings. */
export type TokenMeterConfig = Record<string, never>

View File

@@ -0,0 +1,100 @@
/**
* Pure fold for durable provider-reported token usage.
*/
import { z } from 'zod'
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import type { TokenUsageProjection } from './projection.ts'
interface UsageSample {
turn: number
step: number
buckets: TokenUsageProjection
}
interface TokenUsageState {
totals: TokenUsageProjection
last: UsageSample | null
}
const zeroBuckets = (): TokenUsageProjection => ({
uncachedInputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
})
const bucketsFrom = (usage: TokenUsage): TokenUsageProjection => ({
uncachedInputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cacheReadTokens: usage.cacheReadTokens ?? 0,
cacheWriteTokens: usage.cacheWriteTokens ?? 0,
})
const bucketsEqual = (left: TokenUsageProjection, right: TokenUsageProjection): boolean =>
left.uncachedInputTokens === right.uncachedInputTokens
&& left.outputTokens === right.outputTokens
&& left.cacheReadTokens === right.cacheReadTokens
&& left.cacheWriteTokens === right.cacheWriteTokens
const addReplacing = (
totals: TokenUsageProjection,
previous: TokenUsageProjection | undefined,
next: TokenUsageProjection,
): TokenUsageProjection => ({
uncachedInputTokens: totals.uncachedInputTokens - (previous?.uncachedInputTokens ?? 0) + next.uncachedInputTokens,
outputTokens: totals.outputTokens - (previous?.outputTokens ?? 0) + next.outputTokens,
cacheReadTokens: totals.cacheReadTokens - (previous?.cacheReadTokens ?? 0) + next.cacheReadTokens,
cacheWriteTokens: totals.cacheWriteTokens - (previous?.cacheWriteTokens ?? 0) + next.cacheWriteTokens,
})
const projectionSchema = z.object({
uncachedInputTokens: z.number().int().nonnegative(),
outputTokens: z.number().int().nonnegative(),
cacheReadTokens: z.number().int().nonnegative(),
cacheWriteTokens: z.number().int().nonnegative(),
}).strict()
/**
* Token-meter's session projection unit.
*
* Usage chunks provide an early sample that survives a later request failure;
* an assistant message provides the final sample for the same turn/step. A
* repeated sample replaces that step's earlier value instead of double
* counting it.
*/
export const tokenUsageProjectionDefinition:
ProjectionDefinition<'tokenUsage', TokenUsageState> = {
key: 'tokenUsage',
schema: projectionSchema,
init: () => ({ totals: zeroBuckets(), last: null }),
apply: (state, event) => {
let turn: number
let step: number
let usage: TokenUsage
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
;({ turn, step } = event.data)
usage = event.data.chunk.usage
} else if (event.type === 'assistant/message' && event.data.usage !== undefined) {
;({ turn, step, usage } = event.data)
} else {
return state
}
const buckets = bucketsFrom(usage)
const previous = state.last !== null
&& state.last.turn === turn
&& state.last.step === step
? state.last.buckets
: undefined
if (previous !== undefined && bucketsEqual(previous, buckets)) return state
return {
totals: addReplacing(state.totals, previous, buckets),
last: { turn, step, buckets },
}
},
view: state => state.totals,
stateVersion: 1,
}