Files
deepseek-harness/packages/llm/token-meter/src/turn-usage.ts
T
ZiyaandZiyaZhang b565df3442 feat(web): show exact per-turn token usage (#3005)
* feat(web): show exact per-turn token usage

* test(runtime): refresh exact token usage snapshots

* refactor(token-meter): own per-turn usage folding

* perf(ui-chat): bound paging anchor layout reads

* test(web): align usage golden with system prompt row

* fix(test): resolve token-meter client from source

* test(token-meter): cover retry without usage

---------

Co-authored-by: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com>
2026-08-25 19:05:52 +08:00

272 lines
10 KiB
TypeScript

import type { AssistantMessage, TokenUsage } from '@deepseek-ai/dsh-llm/types'
import type {} from '@deepseek-ai/dsh-llm-retry/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
/** One provider/model route that contributed a billed request attempt. */
export interface TurnTokenUsageRoute {
readonly provider: string
readonly model: string
}
/** Exact provider-reported token accounting for every attempt in one completed Turn. */
export interface TurnTokenUsage {
/** Sum of uncached prompt input across all attempts. */
readonly uncachedInputTokens: number
readonly outputTokens: number
/** Exact aggregate prompt plus output total across all attempts. */
readonly totalTokens: number
/** Present only when every attempt reported the bucket. */
readonly cacheReadTokens?: number
/** Present only when every attempt reported the bucket. */
readonly cacheWriteTokens?: number
/** Output subset, present only when every attempt reported it. */
readonly reasoningTokens?: number
/** Present only when every billed attempt has provider/model attribution. */
readonly routes?: readonly TurnTokenUsageRoute[]
}
interface NormalizedAttempt {
readonly inputTokens: number
readonly outputTokens: number
readonly totalTokens: number
readonly cacheReadTokens?: number
readonly cacheWriteTokens?: number
readonly reasoningTokens?: number
readonly route?: TurnTokenUsageRoute
}
type AttemptState =
| { readonly kind: 'idle' }
| {
readonly kind: 'open'
readonly turn: number
readonly step: number
readonly sample?: TokenUsage
}
| {
readonly kind: 'finishClosed'
readonly turn: number
readonly step: number
}
| {
readonly kind: 'settled'
readonly turn: number
readonly step: number
readonly by: 'message' | 'retry'
}
function isCount(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
}
function safeSum(values: readonly number[]): number | undefined {
let total = 0
for (const value of values) {
total += value
if (!Number.isSafeInteger(total)) return undefined
}
return total
}
function messageRoute(message: AssistantMessage): TurnTokenUsageRoute | undefined {
const { provider, model } = message.source
return provider.length > 0 && model.length > 0 ? { provider, model } : undefined
}
function normalizeUsage(usage: TokenUsage, route?: TurnTokenUsageRoute): NormalizedAttempt | undefined {
const {
inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens, totalTokens,
} = usage
if (!isCount(inputTokens) || !isCount(outputTokens)) return undefined
if (cacheReadTokens !== undefined && !isCount(cacheReadTokens)) return undefined
if (cacheWriteTokens !== undefined && !isCount(cacheWriteTokens)) return undefined
if (reasoningTokens !== undefined && (!isCount(reasoningTokens) || reasoningTokens > outputTokens)) {
return undefined
}
const knownPrompt = safeSum([
inputTokens,
...cacheReadTokens === undefined ? [] : [cacheReadTokens],
...cacheWriteTokens === undefined ? [] : [cacheWriteTokens],
])
if (knownPrompt === undefined) return undefined
let exactTotal: number
if (totalTokens !== undefined) {
if (!isCount(totalTokens)) return undefined
const exactPrompt = totalTokens - outputTokens
if (!isCount(exactPrompt) || exactPrompt < knownPrompt) return undefined
if (cacheReadTokens !== undefined && cacheWriteTokens !== undefined && exactPrompt !== knownPrompt) {
return undefined
}
exactTotal = totalTokens
} else {
if (cacheReadTokens === undefined || cacheWriteTokens === undefined) return undefined
const derivedTotal = safeSum([knownPrompt, outputTokens])
if (derivedTotal === undefined) return undefined
exactTotal = derivedTotal
}
return {
inputTokens,
outputTokens,
totalTokens: exactTotal,
...cacheReadTokens === undefined ? {} : { cacheReadTokens },
...cacheWriteTokens === undefined ? {} : { cacheWriteTokens },
...reasoningTokens === undefined ? {} : { reasoningTokens },
...route === undefined ? {} : { route },
}
}
function aggregateAttempts(attempts: readonly NormalizedAttempt[]): TurnTokenUsage | undefined {
if (attempts.length === 0) return undefined
const inputTokens = safeSum(attempts.map(attempt => attempt.inputTokens))
const outputTokens = safeSum(attempts.map(attempt => attempt.outputTokens))
const totalTokens = safeSum(attempts.map(attempt => attempt.totalTokens))
if (inputTokens === undefined || outputTokens === undefined || totalTokens === undefined) return undefined
const cacheRead = attempts.map(attempt => attempt.cacheReadTokens)
const cacheWrite = attempts.map(attempt => attempt.cacheWriteTokens)
const reasoning = attempts.map(attempt => attempt.reasoningTokens)
const cacheReadTokens = cacheRead.every(isCount) ? safeSum(cacheRead) : undefined
const cacheWriteTokens = cacheWrite.every(isCount) ? safeSum(cacheWrite) : undefined
const reasoningTokens = reasoning.every(isCount) ? safeSum(reasoning) : undefined
// A present cache bucket is bounded by exact prompt, and reasoning is bounded
// by output. Safe required aggregates therefore imply safe optional sums.
let routes: readonly TurnTokenUsageRoute[] | undefined
const attributed = attempts.map(attempt => attempt.route)
if (attributed.every((route): route is TurnTokenUsageRoute => route !== undefined)) {
const unique = new Map<string, TurnTokenUsageRoute>()
for (const route of attributed) unique.set(`${route.provider}\0${route.model}`, route)
routes = [...unique.values()]
}
return {
uncachedInputTokens: inputTokens,
outputTokens,
totalTokens,
...cacheReadTokens === undefined ? {} : { cacheReadTokens },
...cacheWriteTokens === undefined ? {} : { cacheWriteTokens },
...reasoningTokens === undefined ? {} : { reasoningTokens },
...routes === undefined ? {} : { routes },
}
}
function sameAttempt(
state: Exclude<AttemptState, { kind: 'idle' }>,
turn: number,
step: number,
): boolean {
return state.turn === turn && state.step === step
}
/**
* Fold one complete Turn's durable attempt lifecycle into exact token accounting.
*
* No attempt is inferred from a usage sample. Any missing lifecycle boundary,
* incomplete attempt usage, unsafe count, or contradictory exact total makes
* the whole disclosure unavailable.
* @param events - Turn-local durable events from `turn/start` through `turn/end`.
* @returns exact aggregate usage, or undefined when it cannot be proven.
*/
export function deriveTurnTokenUsage(events: readonly SessionEvent[]): TurnTokenUsage | undefined {
let state: AttemptState = { kind: 'idle' }
const attempts: NormalizedAttempt[] = []
let turn: number | undefined
let sawEnd = false
let invalid = false
const closeOpen = (route?: TurnTokenUsageRoute): boolean => {
if (state.kind !== 'open' || state.sample === undefined) return false
const normalized = normalizeUsage(state.sample, route)
if (normalized === undefined) return false
attempts.push(normalized)
return true
}
for (const event of events) {
if (invalid) break
if (event.type === 'turn/start') {
if (turn !== undefined || state.kind !== 'idle') invalid = true
else turn = event.data.turn
continue
}
if (turn === undefined) {
invalid = true
break
}
if (event.type === 'turn/end') {
if (event.data.turn !== turn || state.kind !== 'idle' || sawEnd) invalid = true
else sawEnd = true
continue
}
if (sawEnd) {
invalid = true
break
}
if (event.type === 'step/start') {
if (event.data.turn !== turn || state.kind !== 'idle') invalid = true
else state = { kind: 'open', turn, step: event.data.step }
continue
}
if (event.type === 'llm/retry-started') {
if (event.data.turn !== turn
|| state.kind !== 'settled'
|| state.by !== 'retry'
|| !sameAttempt(state, event.data.turn, event.data.step)) invalid = true
else state = { kind: 'open', turn, step: event.data.step }
continue
}
if (event.type === 'assistant/chunk') {
if (event.data.turn !== turn
|| state.kind !== 'open'
|| !sameAttempt(state, event.data.turn, event.data.step)) {
invalid = true
continue
}
if (event.data.chunk.type === 'usage') {
state = { ...state, sample: event.data.chunk.usage }
} else if (event.data.chunk.type === 'finish'
&& (event.data.chunk.reason.kind === 'error' || event.data.chunk.reason.kind === 'aborted')) {
if (!closeOpen()) invalid = true
else state = { kind: 'finishClosed', turn, step: event.data.step }
}
continue
}
if (event.type === 'assistant/message') {
if (event.data.turn !== turn
|| state.kind !== 'open'
|| !sameAttempt(state, event.data.turn, event.data.step)) {
invalid = true
continue
}
if (event.data.usage !== undefined) state = { ...state, sample: event.data.usage }
if (!closeOpen(messageRoute(event.data.message))) invalid = true
else state = { kind: 'settled', turn, step: event.data.step, by: 'message' }
continue
}
if (event.type === 'llm/retry') {
if (event.data.turn !== turn || state.kind === 'idle'
|| !sameAttempt(state, event.data.turn, event.data.step)) {
invalid = true
continue
}
if (state.kind === 'settled' || (state.kind === 'open' && !closeOpen())) invalid = true
if (!invalid) state = { kind: 'settled', turn, step: event.data.step, by: 'retry' }
continue
}
if (event.type === 'step/end') {
if (event.data.turn !== turn || state.kind === 'idle'
|| !sameAttempt(state, event.data.turn, event.data.step)) {
invalid = true
continue
}
if (state.kind === 'open' && !closeOpen()) invalid = true
if (!invalid) state = { kind: 'idle' }
}
}
return invalid || !sawEnd || state.kind !== 'idle' ? undefined : aggregateAttempts(attempts)
}