1
0

projection.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. /**
  2. * The `sessionStats` projection unit: a pure fold of step boundaries, stream
  3. * chunks, tool pairs, and assembled assistant messages into whole-log counts
  4. * and wall times.
  5. *
  6. * `step/end` — not `assistant/message` — is the counted step event because it
  7. * is the step lifecycle authority: the loop appends exactly one per entered
  8. * step, in a `finally`, so completed, failed, cancelled, and max-tokens steps
  9. * all land one. Counting assembled assistant messages instead would overcount
  10. * max-tokens usage-host messages (empty content, excluded from the surface)
  11. * and undercount cancelled steps (aborted before the message assembles).
  12. *
  13. * The wall-time folds mirror the client window fold field by field
  14. * (`deriveStats` in dsh-client-ui-conversation, that fold's whole-window
  15. * fallback role): model time is `step/start` → `assistant/message`, first
  16. * token is the first non-empty delta chunk and survives an in-step
  17. * `llm/retry`, decode spans first token → assembled message on steps that
  18. * also report output tokens, and tool time pairs `tool/call` → `tool/result`
  19. * by callId. A cancelled step assembles no message, so its partial stream
  20. * time stays uncounted in every time figure — matching the window, which
  21. * renders it as an untimed interrupted node.
  22. *
  23. * @module @deepseek-ai/dsh-session-stats/projection
  24. */
  25. import { z } from 'zod'
  26. import { isTokenDelta } from '@deepseek-ai/dsh-llm/message'
  27. import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
  28. /** Accumulated whole-log figures (the view is exactly these totals). */
  29. interface SessionStatsTotals {
  30. /** Distinct turns with at least one closed step so far. */
  31. turns: number
  32. /** Closed steps so far. */
  33. steps: number
  34. /** Summed model wall time over message-assembling steps, ms. */
  35. llmMs: number
  36. /** Summed matched tool call→result wall time, ms. */
  37. toolMs: number
  38. /** Summed first-token latency over `ttftSteps`, ms. */
  39. ttftMs: number
  40. /** Steps carrying a recorded first token. */
  41. ttftSteps: number
  42. /** Summed decode wall time over usage-reporting steps, ms. */
  43. decodeMs: number
  44. /** Summed provider output tokens over the same steps. */
  45. decodeTokens: number
  46. }
  47. /**
  48. * Fold state: the totals plus the in-flight boundaries they accrue from.
  49. * Turn numbers are host-assigned and monotonic per session, so a single
  50. * `lastTurn` slot decides "first closed step of a new turn"; the state is
  51. * plain JSON per the unit contract (persisted-cache precondition).
  52. */
  53. interface SessionStatsState extends SessionStatsTotals {
  54. /** Turn of the last counted `step/end`; null before the first. */
  55. lastTurn: number | null
  56. /** The open step's boundary facts; null outside a step or after its message assembled. */
  57. openStep: { turn: number; step: number; startTime: number; firstTokenTime: number | null } | null
  58. /** Dispatch times of tool calls whose result has not landed, by callId. */
  59. pendingCalls: Record<string, number>
  60. }
  61. const sessionStatsSchema = z.object({
  62. turns: z.number().int().nonnegative(),
  63. steps: z.number().int().nonnegative(),
  64. llmMs: z.number().nonnegative(),
  65. toolMs: z.number().nonnegative(),
  66. ttftMs: z.number().nonnegative(),
  67. ttftSteps: z.number().int().nonnegative(),
  68. decodeMs: z.number().nonnegative(),
  69. decodeTokens: z.number().nonnegative(),
  70. }).strict()
  71. /**
  72. * Provider-reported completion tokens, guarded the way the window fold guards
  73. * node usage.
  74. * @param usage - the assistant/message event's optional usage record.
  75. * @returns the output-token count, or null when unreported or invalid.
  76. */
  77. function usageOutputTokens(usage: unknown): number | null {
  78. if (typeof usage !== 'object' || usage === null) return null
  79. const value = (usage as { outputTokens?: unknown }).outputTokens
  80. return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
  81. }
  82. /** The `sessionStats` unit registered on `ctx.sessionProjections` (exported for the unit spec). */
  83. export const sessionStatsProjectionDefinition: ProjectionDefinition<'sessionStats', SessionStatsState> = {
  84. key: 'sessionStats',
  85. schema: sessionStatsSchema,
  86. init: () => ({
  87. turns: 0,
  88. steps: 0,
  89. llmMs: 0,
  90. toolMs: 0,
  91. ttftMs: 0,
  92. ttftSteps: 0,
  93. decodeMs: 0,
  94. decodeTokens: 0,
  95. lastTurn: null,
  96. openStep: null,
  97. pendingCalls: {},
  98. }),
  99. apply: (state, event) => {
  100. // Every uninteresting event returns the same reference (Object.is gates the change feed).
  101. switch (event.type) {
  102. case 'step/start':
  103. return {
  104. ...state,
  105. openStep: { turn: event.data.turn, step: event.data.step, startTime: event.time, firstTokenTime: null },
  106. }
  107. case 'assistant/chunk': {
  108. const open = state.openStep
  109. if (open === null || open.turn !== event.data.turn || open.step !== event.data.step) return state
  110. if (open.firstTokenTime !== null || !isTokenDelta(event.data.chunk)) return state
  111. return { ...state, openStep: { ...open, firstTokenTime: event.time } }
  112. }
  113. case 'assistant/message': {
  114. const open = state.openStep
  115. if (open === null || open.turn !== event.data.turn || open.step !== event.data.step) return state
  116. // One assembled message per step: closing the boundary means a
  117. // defensive duplicate cannot accrue twice.
  118. const next: SessionStatsState = {
  119. ...state,
  120. llmMs: state.llmMs + Math.max(0, event.time - open.startTime),
  121. openStep: null,
  122. }
  123. if (open.firstTokenTime !== null) {
  124. next.ttftMs += Math.max(0, open.firstTokenTime - open.startTime)
  125. next.ttftSteps += 1
  126. const outputTokens = usageOutputTokens(event.data.usage)
  127. if (outputTokens !== null) {
  128. next.decodeMs += Math.max(0, event.time - open.firstTokenTime)
  129. next.decodeTokens += outputTokens
  130. }
  131. }
  132. return next
  133. }
  134. case 'tool/call':
  135. return { ...state, pendingCalls: { ...state.pendingCalls, [event.data.callId]: event.time } }
  136. case 'tool/result': {
  137. // Own-key check: callId is provider-minted (model/tool JSON boundary),
  138. // so a prototype property name ('constructor', 'toString') on a result
  139. // with no recorded call must read as unmatched, not as an inherited
  140. // function that would poison toolMs with NaN.
  141. const callId = event.data.message.source.callId
  142. const dispatched = Object.hasOwn(state.pendingCalls, callId) ? state.pendingCalls[callId] : undefined
  143. if (dispatched === undefined) return state
  144. const pendingCalls = Object.fromEntries(
  145. Object.entries(state.pendingCalls).filter(([id]) => id !== callId),
  146. )
  147. return { ...state, toolMs: state.toolMs + Math.max(0, event.time - dispatched), pendingCalls }
  148. }
  149. case 'step/end':
  150. return {
  151. ...state,
  152. turns: state.lastTurn === event.data.turn ? state.turns : state.turns + 1,
  153. steps: state.steps + 1,
  154. lastTurn: event.data.turn,
  155. openStep: null,
  156. }
  157. case 'turn/end':
  158. // A call whose result never landed belongs to a cancelled or failed
  159. // turn; results always land within their turn, so drop the leftovers
  160. // instead of growing persisted state forever.
  161. return Object.keys(state.pendingCalls).length === 0 ? state : { ...state, pendingCalls: {} }
  162. default:
  163. return state
  164. }
  165. },
  166. view: state => ({
  167. turns: state.turns,
  168. steps: state.steps,
  169. llmMs: state.llmMs,
  170. toolMs: state.toolMs,
  171. ttftMs: state.ttftMs,
  172. ttftSteps: state.ttftSteps,
  173. decodeMs: state.decodeMs,
  174. decodeTokens: state.decodeTokens,
  175. }),
  176. stateVersion: 1,
  177. }