request-cache.e2e.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. import { afterEach, describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import LlmService from '@deepseek-ai/dsh-llm'
  4. import SessionStore from '@deepseek-ai/dsh-session'
  5. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  6. import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
  7. import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
  8. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  9. import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
  10. /**
  11. * With-key proof that log-derived requests translate into REAL provider cache
  12. * hits: a multi-step tool turn (plus a follow-up turn) against the live
  13. * DeepSeek API must report `cacheReadTokens > 0` on every request after the
  14. * first — the adapter maps the provider's `prompt_cache_hit_tokens`, and the
  15. * per-step usage recorded on `assistant/message` events is the production
  16. * observable for cache behavior (the reconstructability RFC's measurement
  17. * layer: prefix stability is corollary #1). Mocks prove the requests are
  18. * append-extensions; only the real API proves those bytes actually hit the
  19. * provider cache. Key-gated — skips entirely without $DEEPSEEK_API_KEY.
  20. */
  21. // Long enough that the shared request prefix comfortably spans the provider's
  22. // cache-block granularity (64 tokens) from the very first request.
  23. const SYSTEM = 'You are a terse coding assistant used in an automated cache test. '
  24. + 'Always follow instructions literally and exactly. When the user asks you to look '
  25. + 'something up, call the lookup tool with the requested key and wait for its result '
  26. + 'before answering. Never invent a value the tool has not returned. After the tool '
  27. + 'returns, answer with a single short sentence that repeats the returned value '
  28. + 'verbatim. Do not add explanations, do not use markdown, do not ask follow-up '
  29. + 'questions. If the user asks anything else, answer in one short sentence.'
  30. let ctx: Context | undefined
  31. afterEach(async () => {
  32. await ctx?.fiber.dispose()
  33. ctx = undefined
  34. })
  35. async function loopHarness(): Promise<Context> {
  36. const created = new Context()
  37. await created.plugin(LlmService)
  38. await created.plugin(SessionStore)
  39. await created.plugin(SystemPrompt, { persona: SYSTEM })
  40. await created.plugin(ToolRegistry)
  41. await created.plugin(AgentRegistry)
  42. await created.plugin(AgentLoop, { agents: [] })
  43. await created.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
  44. created.tools.register(defineTool({
  45. name: 'lookup',
  46. description: 'Look up the stored value for a key.',
  47. parameters: { key: { type: 'string', description: 'The key to look up.' } },
  48. async execute(args) {
  49. return [{ type: 'text', text: `value(${String(args.key)}) = azure-falcon-42` }]
  50. },
  51. }))
  52. return created
  53. }
  54. function waitForIdle(context: Context, agent: Agent): Promise<void> {
  55. return new Promise((resolve) => {
  56. const dispose = context.on('agent/status', (subject, status) => {
  57. if (subject === agent && status === 'idle') {
  58. dispose()
  59. resolve()
  60. }
  61. })
  62. })
  63. }
  64. describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits (real API)', () => {
  65. it('every request after the first hits the provider prefix cache', async () => {
  66. ctx = await loopHarness()
  67. const agent = ctx.agentLoop.create(AgentId('cache-e2e'), { model: 'deepseek-v4-flash' })
  68. // Turn 1: forces a tool call → at least two steps (two model requests).
  69. agent.send([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }])
  70. await waitForIdle(ctx, agent)
  71. // Turn 2: a follow-up over the same (longer) prefix.
  72. agent.send([{ type: 'text', text: 'Thanks. Repeat that value one more time.' }])
  73. await waitForIdle(ctx, agent)
  74. const usages = [...agent.session.events]
  75. .filter(e => e.type === 'assistant/message')
  76. .map(e => e.data.usage)
  77. expect(usages.length).toBeGreaterThanOrEqual(3) // 2 steps in turn 1 + ≥1 in turn 2
  78. for (const usage of usages) expect(usage).toBeDefined()
  79. // The first request has nothing to hit; every later one shares its
  80. // predecessor as a byte-identical prefix, so the provider must report
  81. // cached prompt tokens (prompt_cache_hit_tokens → cacheReadTokens).
  82. for (const usage of usages.slice(1)) {
  83. expect(usage!.cacheReadTokens ?? 0).toBeGreaterThan(0)
  84. }
  85. // World-verification of the conversation itself: the tool value made it
  86. // through the loop into the final answer.
  87. const finalText = agent.session.deriveMessages().at(-1)!.content
  88. .filter(block => block.type === 'text')
  89. .map(block => block.text)
  90. .join('')
  91. expect(finalText).toContain('azure-falcon-42')
  92. }, 180_000)
  93. })