request-cache.e2e.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  2. import { afterEach, describe, expect, it } from 'vitest'
  3. import { Context } from '@deepseek-ai/cordis'
  4. import LlmRuntime from '@deepseek-ai/dsh-llm'
  5. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  6. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  7. import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
  8. import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
  9. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  10. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  11. import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
  12. /**
  13. * With-key proof that log-derived requests translate into real provider cache hits: a
  14. * multi-step tool turn (plus a follow-up turn) against the live DeepSeek API must report
  15. * `cacheReadTokens > 0` on every request after the first — the adapter maps the provider's
  16. * `prompt_cache_hit_tokens`, and the per-step usage recorded on `assistant/message` events is
  17. * the production observable for cache behavior (the reconstructability Agent Note's measurement
  18. * layer: prefix stability is corollary #1). Mocks establish append-extension;
  19. * this key-gated test establishes a real provider cache hit.
  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(LlmRuntime)
  38. await created.plugin(SessionStore)
  39. await created.plugin(SessionProjectionRegistry)
  40. await created.plugin(SystemPrompt, { persona: SYSTEM })
  41. await created.plugin(ToolRuntime)
  42. await created.plugin(AgentRegistry)
  43. await created.plugin(AgentLoop, { agents: [] })
  44. await created.plugin(LlmDeepSeek)
  45. created.tools.register(defineContentToolFixture({
  46. name: 'lookup',
  47. description: 'Look up the stored value for a key.',
  48. parameters: { key: { type: 'string', description: 'The key to look up.' } },
  49. async execute(args) {
  50. return [{ type: 'text', text: `value(${String(args.key)}) = azure-falcon-42` }]
  51. },
  52. }))
  53. return created
  54. }
  55. function waitForIdle(context: Context, agent: Agent): Promise<void> {
  56. return new Promise((resolve) => {
  57. const dispose = context.on('agent/status', ({ agent: subject, status }) => {
  58. if (subject === agent && status === 'idle') {
  59. dispose()
  60. resolve()
  61. }
  62. })
  63. })
  64. }
  65. describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits (real API)', () => {
  66. it('every request after the first hits the provider prefix cache', async () => {
  67. ctx = await loopHarness()
  68. const agent = await ctx.agentLoop.create(SessionId('cache-e2e'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' })
  69. // Turn 1: forces a tool call → at least two steps (two model requests).
  70. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }], source: { kind: 'user' } }))
  71. await waitForIdle(ctx, agent)
  72. // Turn 2: a follow-up over the same (longer) prefix.
  73. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Thanks. Repeat that value one more time.' }], source: { kind: 'user' } }))
  74. await waitForIdle(ctx, agent)
  75. const usages = agent.session.snapshotEvents()
  76. .filter(e => e.type === 'assistant/message')
  77. .map(e => e.data.usage)
  78. expect(usages.length).toBeGreaterThanOrEqual(3) // 2 steps in turn 1 + ≥1 in turn 2
  79. for (const usage of usages) expect(usage).toBeDefined()
  80. // The first request has nothing to hit; every later one shares its
  81. // predecessor as a byte-identical prefix, so the provider must report
  82. // cached prompt tokens (prompt_cache_hit_tokens → cacheReadTokens).
  83. for (const usage of usages.slice(1)) {
  84. expect(usage!.cacheReadTokens ?? 0).toBeGreaterThan(0)
  85. }
  86. // World-verification of the conversation itself: the tool value made it
  87. // through the loop into the final answer.
  88. const finalText = agent.session.deriveMessages().at(-1)!.content
  89. .filter(block => block.type === 'text')
  90. .map(block => block.text)
  91. .join('')
  92. expect(finalText).toContain('azure-falcon-42')
  93. }, 180_000)
  94. })