| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183 |
- /**
- * 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,
- }
|