- stateVersion starts at 1: the package is new, no persisted rows predate it - pendingCalls pairs by own key so a provider callId naming a prototype property cannot fold toolMs to NaN on an unmatched crash-recovery result - StatsLine folds the window fallback only when no sessionStats value is served, and gates the token group on actual token activity instead of steps, so failed-only sessions drop the zero-token group - correct the crash-step counting semantics in the README and Agent Note: recovery closes interrupted steps with a synthetic step/end on reload - reword the window-scoped alternative as a plain rejected option and name isTokenDelta's home beside the StreamChunk type
184 lines
7.6 KiB
TypeScript
184 lines
7.6 KiB
TypeScript
/**
|
|
* The `sessionStats` projection unit: a pure fold of step boundaries, stream
|
|
* chunks, tool pairs, and assembled assistant messages into whole-log counts
|
|
* and wall times.
|
|
*
|
|
* `step/end` — not `assistant/message` — is the counted step event because it
|
|
* is the step lifecycle authority: the loop appends exactly one per entered
|
|
* step, in a `finally`, so completed, failed, cancelled, and max-tokens steps
|
|
* all land one. Counting assembled assistant messages instead would overcount
|
|
* max-tokens usage-host messages (empty content, excluded from the surface)
|
|
* and undercount cancelled steps (aborted before the message assembles).
|
|
*
|
|
* The wall-time folds mirror the client window fold field by field
|
|
* (`deriveStats` in dsh-client-ui-conversation, that fold's whole-window
|
|
* fallback role): model time is `step/start` → `assistant/message`, first
|
|
* token is the first non-empty delta chunk and survives an in-step
|
|
* `llm/retry`, decode spans first token → assembled message on steps that
|
|
* also report output tokens, and tool time pairs `tool/call` → `tool/result`
|
|
* by callId. A cancelled step assembles no message, so its partial stream
|
|
* time stays uncounted in every time figure — matching the window, which
|
|
* renders it as an untimed interrupted node.
|
|
*
|
|
* @module @deepseek-ai/dsh-session-stats/projection
|
|
*/
|
|
|
|
import { z } from 'zod'
|
|
import { isTokenDelta } from '@deepseek-ai/dsh-llm/message'
|
|
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
|
|
|
/** Accumulated whole-log figures (the view is exactly these totals). */
|
|
interface SessionStatsTotals {
|
|
/** Distinct turns with at least one closed step so far. */
|
|
turns: number
|
|
/** Closed steps so far. */
|
|
steps: number
|
|
/** Summed model wall time over message-assembling steps, ms. */
|
|
llmMs: number
|
|
/** Summed matched tool call→result wall time, ms. */
|
|
toolMs: number
|
|
/** Summed first-token latency over `ttftSteps`, ms. */
|
|
ttftMs: number
|
|
/** Steps carrying a recorded first token. */
|
|
ttftSteps: number
|
|
/** Summed decode wall time over usage-reporting steps, ms. */
|
|
decodeMs: number
|
|
/** Summed provider output tokens over the same steps. */
|
|
decodeTokens: number
|
|
}
|
|
|
|
/**
|
|
* Fold state: the totals plus the in-flight boundaries they accrue from.
|
|
* Turn numbers are host-assigned and monotonic per session, so a single
|
|
* `lastTurn` slot decides "first closed step of a new turn"; the state is
|
|
* plain JSON per the unit contract (persisted-cache precondition).
|
|
*/
|
|
interface SessionStatsState extends SessionStatsTotals {
|
|
/** Turn of the last counted `step/end`; null before the first. */
|
|
lastTurn: number | null
|
|
/** The open step's boundary facts; null outside a step or after its message assembled. */
|
|
openStep: { turn: number; step: number; startTime: number; firstTokenTime: number | null } | null
|
|
/** Dispatch times of tool calls whose result has not landed, by callId. */
|
|
pendingCalls: Record<string, number>
|
|
}
|
|
|
|
const sessionStatsSchema = z.object({
|
|
turns: z.number().int().nonnegative(),
|
|
steps: z.number().int().nonnegative(),
|
|
llmMs: z.number().nonnegative(),
|
|
toolMs: z.number().nonnegative(),
|
|
ttftMs: z.number().nonnegative(),
|
|
ttftSteps: z.number().int().nonnegative(),
|
|
decodeMs: z.number().nonnegative(),
|
|
decodeTokens: z.number().nonnegative(),
|
|
}).strict()
|
|
|
|
/**
|
|
* Provider-reported completion tokens, guarded the way the window fold guards
|
|
* node usage.
|
|
* @param usage - the assistant/message event's optional usage record.
|
|
* @returns the output-token count, or null when unreported or invalid.
|
|
*/
|
|
function usageOutputTokens(usage: unknown): number | null {
|
|
if (typeof usage !== 'object' || usage === null) return null
|
|
const value = (usage as { outputTokens?: unknown }).outputTokens
|
|
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
|
|
}
|
|
|
|
/** The `sessionStats` unit registered on `ctx.sessionProjections` (exported for the unit spec). */
|
|
export const sessionStatsProjectionDefinition: ProjectionDefinition<'sessionStats', SessionStatsState> = {
|
|
key: 'sessionStats',
|
|
schema: sessionStatsSchema,
|
|
init: () => ({
|
|
turns: 0,
|
|
steps: 0,
|
|
llmMs: 0,
|
|
toolMs: 0,
|
|
ttftMs: 0,
|
|
ttftSteps: 0,
|
|
decodeMs: 0,
|
|
decodeTokens: 0,
|
|
lastTurn: null,
|
|
openStep: null,
|
|
pendingCalls: {},
|
|
}),
|
|
apply: (state, event) => {
|
|
// Every uninteresting event returns the same reference (Object.is gates the change feed).
|
|
switch (event.type) {
|
|
case 'step/start':
|
|
return {
|
|
...state,
|
|
openStep: { turn: event.data.turn, step: event.data.step, startTime: event.time, firstTokenTime: null },
|
|
}
|
|
case 'assistant/chunk': {
|
|
const open = state.openStep
|
|
if (open === null || open.turn !== event.data.turn || open.step !== event.data.step) return state
|
|
if (open.firstTokenTime !== null || !isTokenDelta(event.data.chunk)) return state
|
|
return { ...state, openStep: { ...open, firstTokenTime: event.time } }
|
|
}
|
|
case 'assistant/message': {
|
|
const open = state.openStep
|
|
if (open === null || open.turn !== event.data.turn || open.step !== event.data.step) return state
|
|
// One assembled message per step: closing the boundary means a
|
|
// defensive duplicate cannot accrue twice.
|
|
const next: SessionStatsState = {
|
|
...state,
|
|
llmMs: state.llmMs + Math.max(0, event.time - open.startTime),
|
|
openStep: null,
|
|
}
|
|
if (open.firstTokenTime !== null) {
|
|
next.ttftMs += Math.max(0, open.firstTokenTime - open.startTime)
|
|
next.ttftSteps += 1
|
|
const outputTokens = usageOutputTokens(event.data.usage)
|
|
if (outputTokens !== null) {
|
|
next.decodeMs += Math.max(0, event.time - open.firstTokenTime)
|
|
next.decodeTokens += outputTokens
|
|
}
|
|
}
|
|
return next
|
|
}
|
|
case 'tool/call':
|
|
return { ...state, pendingCalls: { ...state.pendingCalls, [event.data.callId]: event.time } }
|
|
case 'tool/result': {
|
|
// Own-key check: callId is provider-minted (model/tool JSON boundary),
|
|
// so a prototype property name ('constructor', 'toString') on a result
|
|
// with no recorded call must read as unmatched, not as an inherited
|
|
// function that would poison toolMs with NaN.
|
|
const callId = event.data.message.source.callId
|
|
const dispatched = Object.hasOwn(state.pendingCalls, callId) ? state.pendingCalls[callId] : undefined
|
|
if (dispatched === undefined) return state
|
|
const pendingCalls = Object.fromEntries(
|
|
Object.entries(state.pendingCalls).filter(([id]) => id !== callId),
|
|
)
|
|
return { ...state, toolMs: state.toolMs + Math.max(0, event.time - dispatched), pendingCalls }
|
|
}
|
|
case 'step/end':
|
|
return {
|
|
...state,
|
|
turns: state.lastTurn === event.data.turn ? state.turns : state.turns + 1,
|
|
steps: state.steps + 1,
|
|
lastTurn: event.data.turn,
|
|
openStep: null,
|
|
}
|
|
case 'turn/end':
|
|
// A call whose result never landed belongs to a cancelled or failed
|
|
// turn; results always land within their turn, so drop the leftovers
|
|
// instead of growing persisted state forever.
|
|
return Object.keys(state.pendingCalls).length === 0 ? state : { ...state, pendingCalls: {} }
|
|
default:
|
|
return state
|
|
}
|
|
},
|
|
view: state => ({
|
|
turns: state.turns,
|
|
steps: state.steps,
|
|
llmMs: state.llmMs,
|
|
toolMs: state.toolMs,
|
|
ttftMs: state.ttftMs,
|
|
ttftSteps: state.ttftSteps,
|
|
decodeMs: state.decodeMs,
|
|
decodeTokens: state.decodeTokens,
|
|
}),
|
|
stateVersion: 1,
|
|
}
|