token-usage-projection.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
  4. import type { TokenUsage } from '@deepseek-ai/dsh-llm'
  5. import SessionStore from '@deepseek-ai/dsh-session'
  6. import type { Session } from '@deepseek-ai/dsh-session'
  7. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  8. import TokenMeterService from '@deepseek-ai/dsh-token-meter'
  9. import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
  10. const ZERO: TokenUsageProjection = {
  11. uncachedInputTokens: 0,
  12. outputTokens: 0,
  13. cacheReadTokens: 0,
  14. cacheWriteTokens: 0,
  15. }
  16. async function harness(): Promise<{
  17. ctx: Context
  18. session: Session
  19. meterFiber: Awaited<ReturnType<Context['plugin']>>
  20. }> {
  21. const ctx = new Context()
  22. await ctx.plugin(SessionStore)
  23. await ctx.plugin(SessionProjectionRegistry)
  24. const meterFiber = await ctx.plugin(TokenMeterService)
  25. return { ctx, session: ctx.sessions.create(), meterFiber }
  26. }
  27. function startStep(session: Session, turn: number, step: number): void {
  28. session.append('step/start', { turn, step })
  29. }
  30. function usageChunk(
  31. session: Session,
  32. usage: TokenUsage,
  33. turn: number,
  34. step: number,
  35. ): number {
  36. return session.append('assistant/chunk', {
  37. turn,
  38. step,
  39. chunk: { type: 'usage', usage },
  40. }).seq
  41. }
  42. function finalUsage(
  43. session: Session,
  44. usage: TokenUsage,
  45. turn: number,
  46. step: number,
  47. sourceSeqs: number[],
  48. ): void {
  49. session.append('assistant/message', {
  50. turn,
  51. step,
  52. message: createMessage({
  53. role: 'assistant',
  54. content: [],
  55. source: { kind: 'model', provider: 'mock', model: 'mock' },
  56. }),
  57. usage,
  58. }, { surfaceOp: 'append', sourceEventSeqs: sourceSeqs })
  59. session.append('step/end', { turn, step })
  60. }
  61. const projected = (ctx: Context, session: Session): TokenUsageProjection => {
  62. const value = ctx.sessionProjections.snapshot(session).values.tokenUsage
  63. if (value === undefined) throw new Error('tokenUsage projection is not registered')
  64. return value
  65. }
  66. /**
  67. * Meter one upcoming replacement the way compact-basic does: price the
  68. * replaced span from the measurement service's own nodes and log the
  69. * shadow-price event directly before the replace.
  70. */
  71. function appendSummaryMeter(ctx: Context, session: Session, start: number, end: number): void {
  72. const nodes = ctx.tokenMeter.measure(session).nodes
  73. const startIdx = nodes.findIndex(node => node.seq === start)
  74. const endIdx = nodes.findIndex(node => node.seq === end)
  75. const shadowed = nodes.slice(startIdx, endIdx + 1)
  76. session.append('compact/summary', {
  77. summary: [{ type: 'text', text: 'summary' }],
  78. shadowedRange: { start, end },
  79. shadowedSeqs: shadowed.map(node => node.seq),
  80. shadowedTokenCount: shadowed.reduce((total, node) => total + node.tokens, 0),
  81. provider: 'mock',
  82. model: 'mock',
  83. })
  84. }
  85. describe('tokenUsage session projection', () => {
  86. it('serves zero buckets for an empty log', async () => {
  87. const { ctx, session } = await harness()
  88. expect(projected(ctx, session)).toEqual(ZERO)
  89. })
  90. it('does not count a usage chunk and identical final usage twice', async () => {
  91. const { ctx, session } = await harness()
  92. const changes: unknown[] = []
  93. ctx.sessionProjections.onChanged((_session, key, value) => {
  94. if (key === 'tokenUsage') changes.push(value)
  95. })
  96. const usage = {
  97. inputTokens: 10,
  98. outputTokens: 4,
  99. cacheReadTokens: 7,
  100. cacheWriteTokens: 2,
  101. reasoningTokens: 3,
  102. }
  103. startStep(session, 1, 1)
  104. const source = usageChunk(session, usage, 1, 1)
  105. finalUsage(session, usage, 1, 1, [source])
  106. expect(projected(ctx, session)).toEqual({
  107. uncachedInputTokens: 10,
  108. outputTokens: 4,
  109. cacheReadTokens: 7,
  110. cacheWriteTokens: 2,
  111. })
  112. expect(changes).toHaveLength(1)
  113. })
  114. it('replaces an earlier same-step chunk sample with the final usage', async () => {
  115. const { ctx, session } = await harness()
  116. startStep(session, 1, 1)
  117. const source = usageChunk(session, {
  118. inputTokens: 10,
  119. outputTokens: 2,
  120. cacheReadTokens: 3,
  121. }, 1, 1)
  122. finalUsage(session, {
  123. inputTokens: 14,
  124. outputTokens: 5,
  125. cacheReadTokens: 8,
  126. cacheWriteTokens: 1,
  127. }, 1, 1, [source])
  128. expect(projected(ctx, session)).toEqual({
  129. uncachedInputTokens: 14,
  130. outputTokens: 5,
  131. cacheReadTokens: 8,
  132. cacheWriteTokens: 1,
  133. })
  134. })
  135. it('accumulates disjoint buckets across steps without adding reasoning twice', async () => {
  136. const { ctx, session } = await harness()
  137. startStep(session, 1, 1)
  138. const first = usageChunk(session, {
  139. inputTokens: 10,
  140. outputTokens: 6,
  141. reasoningTokens: 5,
  142. cacheReadTokens: 2,
  143. }, 1, 1)
  144. finalUsage(session, {
  145. inputTokens: 10,
  146. outputTokens: 6,
  147. reasoningTokens: 5,
  148. cacheReadTokens: 2,
  149. }, 1, 1, [first])
  150. startStep(session, 1, 2)
  151. const second = usageChunk(session, {
  152. inputTokens: 20,
  153. outputTokens: 9,
  154. reasoningTokens: 7,
  155. cacheWriteTokens: 4,
  156. }, 1, 2)
  157. finalUsage(session, {
  158. inputTokens: 20,
  159. outputTokens: 9,
  160. reasoningTokens: 7,
  161. cacheWriteTokens: 4,
  162. }, 1, 2, [second])
  163. expect(projected(ctx, session)).toEqual({
  164. uncachedInputTokens: 30,
  165. outputTokens: 15,
  166. cacheReadTokens: 2,
  167. cacheWriteTokens: 4,
  168. })
  169. })
  170. it('retains a usage chunk when the request produces no final assistant message', async () => {
  171. const { ctx, session } = await harness()
  172. startStep(session, 1, 1)
  173. usageChunk(session, { inputTokens: 9, outputTokens: 1 }, 1, 1)
  174. session.append('step/end', { turn: 1, step: 1 })
  175. expect(projected(ctx, session)).toEqual({
  176. uncachedInputTokens: 9,
  177. outputTokens: 1,
  178. cacheReadTokens: 0,
  179. cacheWriteTokens: 0,
  180. })
  181. })
  182. it('does not erase historical billing when the visible surface is replaced', async () => {
  183. const { ctx, session } = await harness()
  184. startStep(session, 1, 1)
  185. const source = usageChunk(session, { inputTokens: 12, outputTokens: 3 }, 1, 1)
  186. finalUsage(session, { inputTokens: 12, outputTokens: 3 }, 1, 1, [source])
  187. const before = session.append('user/message', createUserMessage({
  188. content: [{ type: 'text', text: 'before compaction' }],
  189. source: { kind: 'user' },
  190. }), { surfaceOp: 'append' })
  191. appendSummaryMeter(ctx, session, before.seq, before.seq)
  192. session.append('user/message', createUserMessage({
  193. content: [{ type: 'text', text: 'compacted' }],
  194. source: { kind: 'plugin', plugin: 'test' },
  195. }), {
  196. surfaceOp: { op: 'replace', start: before.seq, end: before.seq },
  197. sourceEventSeqs: [before.seq],
  198. })
  199. expect(projected(ctx, session)).toEqual({
  200. uncachedInputTokens: 12,
  201. outputTokens: 3,
  202. cacheReadTokens: 0,
  203. cacheWriteTokens: 0,
  204. })
  205. })
  206. it('unregisters with the token-meter fiber and restores from a JSON checkpoint', async () => {
  207. const { ctx, session, meterFiber } = await harness()
  208. startStep(session, 1, 1)
  209. usageChunk(session, { inputTokens: 8, outputTokens: 2, cacheReadTokens: 5 }, 1, 1)
  210. const checkpoint = JSON.parse(JSON.stringify(
  211. ctx.sessionProjections.checkpoint(session),
  212. )) as ReturnType<typeof ctx.sessionProjections.checkpoint>
  213. await meterFiber.dispose()
  214. expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('tokenUsage')
  215. await ctx.plugin(TokenMeterService)
  216. expect(ctx.sessionProjections.viewCheckpoint(checkpoint).tokenUsage).toEqual({
  217. uncachedInputTokens: 8,
  218. outputTokens: 2,
  219. cacheReadTokens: 5,
  220. cacheWriteTokens: 0,
  221. })
  222. })
  223. })
  224. const pressure = (ctx: Context, session: Session): ContextPressureProjection => {
  225. const value = ctx.sessionProjections.snapshot(session).values.contextPressure
  226. if (value === undefined) throw new Error('contextPressure projection is not registered')
  227. return value
  228. }
  229. function recordContext(session: Session, model: string, contextWindow?: number): void {
  230. session.append('request/context', {
  231. provider: 'mock',
  232. model,
  233. ...contextWindow === undefined ? {} : { contextWindow },
  234. })
  235. }
  236. /** Append one model-visible user turn and return its surface seq. */
  237. function appendUser(session: Session, text: string): number {
  238. return session.append('user/message', createUserMessage({
  239. content: [{ type: 'text', text }],
  240. source: { kind: 'user' },
  241. }), { surfaceOp: 'append' }).seq
  242. }
  243. /** Append one finalized assistant turn carrying its provider usage. */
  244. function appendAssistant(
  245. session: Session,
  246. text: string,
  247. usage: TokenUsage,
  248. turn: number,
  249. step: number,
  250. ): number {
  251. return session.append('assistant/message', {
  252. turn,
  253. step,
  254. message: createMessage({
  255. role: 'assistant',
  256. content: [{ type: 'text', text }],
  257. source: { kind: 'model', provider: 'mock', model: 'mock' },
  258. }),
  259. usage,
  260. }, { surfaceOp: 'append', sourceEventSeqs: [] }).seq
  261. }
  262. describe('contextPressure session projection', () => {
  263. it('serves no pressure or capacity for an empty log', async () => {
  264. const { ctx, session } = await harness()
  265. expect(pressure(ctx, session)).toEqual({})
  266. })
  267. it('does not synthesize zero pressure before a provider usage sample', async () => {
  268. const { ctx, session } = await harness()
  269. startStep(session, 1, 1)
  270. recordContext(session, 'small', 64_000)
  271. expect(pressure(ctx, session)).toEqual({ contextWindow: 64_000 })
  272. })
  273. it('sums prompt-side buckets and excludes response output', async () => {
  274. const { ctx, session } = await harness()
  275. startStep(session, 1, 1)
  276. usageChunk(session, {
  277. inputTokens: 100,
  278. outputTokens: 4_000,
  279. cacheReadTokens: 20,
  280. cacheWriteTokens: 5,
  281. }, 1, 1)
  282. // Output is deliberately absent: occupancy describes the prompt that was
  283. // sent, so it holds still while the response streams.
  284. expect(pressure(ctx, session).pressureTokens).toBe(125)
  285. })
  286. it('replaces pressure with the newest request rather than accumulating', async () => {
  287. const { ctx, session } = await harness()
  288. startStep(session, 1, 1)
  289. const first = usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1)
  290. finalUsage(session, { inputTokens: 100, outputTokens: 10 }, 1, 1, [first])
  291. startStep(session, 2, 1)
  292. usageChunk(session, { inputTokens: 250, outputTokens: 10 }, 2, 1)
  293. expect(pressure(ctx, session).pressureTokens).toBe(250)
  294. })
  295. it('carries the newest recorded capacity and replaces it on a model switch', async () => {
  296. const { ctx, session } = await harness()
  297. startStep(session, 1, 1)
  298. recordContext(session, 'small', 64_000)
  299. usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1)
  300. expect(pressure(ctx, session)).toEqual({
  301. pressureTokens: 100, projectedTokens: 100, contextWindow: 64_000,
  302. })
  303. recordContext(session, 'large', 256_000)
  304. expect(pressure(ctx, session)).toEqual({
  305. pressureTokens: 100, projectedTokens: 100, contextWindow: 256_000,
  306. })
  307. })
  308. it('removes an older capacity when the newest route advertises none', async () => {
  309. const { ctx, session } = await harness()
  310. startStep(session, 1, 1)
  311. recordContext(session, 'small', 64_000)
  312. usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1)
  313. recordContext(session, 'unknown')
  314. expect(pressure(ctx, session)).toEqual({ pressureTokens: 100, projectedTokens: 100 })
  315. })
  316. it('pushes no change for unrelated events or a restated capacity', async () => {
  317. // The registry gates its change feed on Object.is, so a unit that rebuilt
  318. // state for an event it does not care about would push phantom updates.
  319. const { ctx, session } = await harness()
  320. startStep(session, 1, 1)
  321. recordContext(session, 'small', 64_000)
  322. usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1)
  323. const changed: string[] = []
  324. ctx.sessionProjections.onChanged((_session, key) => { changed.push(key) })
  325. session.append('todo/write', { todos: [] })
  326. expect(changed).not.toContain('contextPressure')
  327. // A repeated capacity record for the same window is also a no-op.
  328. recordContext(session, 'small', 64_000)
  329. expect(changed).not.toContain('contextPressure')
  330. // A real capacity change still reports.
  331. recordContext(session, 'large', 256_000)
  332. expect(changed).toContain('contextPressure')
  333. })
  334. it('restores from a JSON checkpoint and unregisters with the token-meter fiber', async () => {
  335. const { ctx, session, meterFiber } = await harness()
  336. startStep(session, 1, 1)
  337. recordContext(session, 'small', 64_000)
  338. usageChunk(session, { inputTokens: 42, outputTokens: 2 }, 1, 1)
  339. const checkpoint = JSON.parse(JSON.stringify(
  340. ctx.sessionProjections.checkpoint(session),
  341. )) as ReturnType<typeof ctx.sessionProjections.checkpoint>
  342. expect(checkpoint.contextPressure?.ver).toBe(4)
  343. await meterFiber.dispose()
  344. expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('contextPressure')
  345. await ctx.plugin(TokenMeterService)
  346. expect(ctx.sessionProjections.viewCheckpoint(checkpoint).contextPressure).toEqual({
  347. pressureTokens: 42,
  348. projectedTokens: 42,
  349. contextWindow: 64_000,
  350. })
  351. })
  352. it('carries the sample forward over surface growth and a compaction', async () => {
  353. const { ctx, session } = await harness()
  354. recordContext(session, 'large', 128_000)
  355. const question = appendUser(session, 'a first question worth a few tokens')
  356. startStep(session, 1, 1)
  357. // The provider prices the prompt its request actually carried; the sample
  358. // must anchor against the surface as of that request, not after the
  359. // assistant message joins it.
  360. const answer = appendAssistant(session, 'an answer of some length', { inputTokens: 900, outputTokens: 20 }, 1, 1)
  361. session.append('step/end', { turn: 1, step: 1 })
  362. const afterTurn = pressure(ctx, session)
  363. expect(afterTurn.pressureTokens).toBe(900)
  364. // The assistant message landed after the sample, so it already shows.
  365. expect(afterTurn.projectedTokens).toBeGreaterThan(900)
  366. const grown = appendUser(session, 'a follow-up question that grows the surface further')
  367. const beforeCompaction = pressure(ctx, session).projectedTokens
  368. expect(beforeCompaction).toBeGreaterThan(afterTurn.projectedTokens!)
  369. // Compaction reports no usage of its own, so `pressureTokens` cannot move;
  370. // the projected figure must shrink anyway — the defect this field fixes.
  371. appendSummaryMeter(ctx, session, question, grown)
  372. session.append('user/message', createUserMessage({
  373. content: [{ type: 'text', text: 'summary' }],
  374. source: { kind: 'plugin', plugin: 'test' },
  375. }), {
  376. surfaceOp: { op: 'replace', start: question, end: grown },
  377. sourceEventSeqs: [question, answer, grown],
  378. })
  379. const compacted = pressure(ctx, session)
  380. expect(compacted.pressureTokens).toBe(900)
  381. expect(compacted.projectedTokens).toBeLessThan(beforeCompaction!)
  382. })
  383. it('clamps a projection that heuristic error drove below zero', async () => {
  384. const { ctx, session } = await harness()
  385. recordContext(session, 'large', 128_000)
  386. const question = appendUser(session, 'a question long enough to outprice the sample'.repeat(4))
  387. startStep(session, 1, 1)
  388. // A provider sample far below the heuristic price of what it replaced:
  389. // shadowing that span subtracts more than the sample holds.
  390. appendAssistant(session, 'ok', { inputTokens: 3, outputTokens: 1 }, 1, 1)
  391. session.append('step/end', { turn: 1, step: 1 })
  392. appendSummaryMeter(ctx, session, question, question)
  393. session.append('user/message', createUserMessage({
  394. content: [{ type: 'text', text: '.' }],
  395. source: { kind: 'plugin', plugin: 'test' },
  396. }), {
  397. surfaceOp: { op: 'replace', start: question, end: question },
  398. sourceEventSeqs: [question],
  399. })
  400. expect(pressure(ctx, session).projectedTokens).toBe(0)
  401. })
  402. })