request-cache.e2e.ts 4.8 KB

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