token-usage-projection.spec.ts 17 KB

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