cache-policy.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. // Apply an `LLMRequest.cache` policy by injecting `CacheHint`s onto the parts
  2. // the policy designates. Runs once at compile time, before the per-protocol
  3. // body builder, so the existing inline-hint lowering path handles the rest.
  4. //
  5. // The default `"auto"` shape places breakpoints at the last tool definition,
  6. // the first and last distinct system parts, and the conversation tail. This
  7. // exposes reusable tool, base-agent, project, and session prefixes while
  8. // advancing the tail after each tool result keeps the previous cache entry
  9. // within Anthropic's 20-block lookback during long agent turns.
  10. //
  11. // Manual `cache: CacheHint` placements on individual parts are preserved and
  12. // count against the four-breakpoint budget; auto only fills remaining slots.
  13. import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options"
  14. import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages"
  15. const AUTO: CachePolicyObject = {
  16. tools: true,
  17. system: true,
  18. messages: { tail: 1 },
  19. }
  20. const NONE: CachePolicyObject = {}
  21. const BREAKPOINT_CAP = 4
  22. // Resolution rules:
  23. // - undefined → "auto" — caching is on by default. The math favors it:
  24. // Anthropic 5m-cache write is 1.25x base, read is 0.1x,
  25. // so a single reuse within 5 minutes already wins.
  26. // - "auto" → tools + first/last system + final message boundary.
  27. // - "none" → no auto placement; manual `CacheHint`s still flow.
  28. // - object form → exactly what the caller asked for.
  29. const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
  30. if (policy === undefined || policy === "auto") return AUTO
  31. if (policy === "none") return NONE
  32. return policy
  33. }
  34. // Protocols whose wire format ignores inline cache markers (OpenAI's implicit
  35. // prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
  36. // whole policy pass for these — emitting hints would be harmless but pointless.
  37. const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"])
  38. const makeHint = (ttlSeconds: number | undefined): CacheHint =>
  39. ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
  40. interface Budget {
  41. remaining: number
  42. }
  43. const markLastTool = (
  44. tools: ReadonlyArray<ToolDefinition>,
  45. hint: CacheHint,
  46. budget: Budget,
  47. ): ReadonlyArray<ToolDefinition> => {
  48. if (tools.length === 0) return tools
  49. const last = tools.length - 1
  50. if (tools[last]!.cache || budget.remaining === 0) return tools
  51. budget.remaining -= 1
  52. return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool))
  53. }
  54. const markSystemBoundaries = (system: LLMRequest["system"], hint: CacheHint, budget: Budget): LLMRequest["system"] => {
  55. if (system.length === 0) return system
  56. let changed = false
  57. const next = system.map((part, index) => {
  58. if ((index !== 0 && index !== system.length - 1) || part.cache || budget.remaining === 0) return part
  59. budget.remaining -= 1
  60. changed = true
  61. return { ...part, cache: hint }
  62. })
  63. return changed ? next : system
  64. }
  65. const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]): number =>
  66. messages.findLastIndex((m) => m.role === role)
  67. // Mark the last text part of `messages[index]`. If no text part exists, mark
  68. // the last content part regardless of type — that's the breakpoint position
  69. // in tool-result-only messages too.
  70. const markMessageAt = (
  71. messages: ReadonlyArray<Message>,
  72. index: number,
  73. hint: CacheHint,
  74. budget: Budget,
  75. ): ReadonlyArray<Message> => {
  76. if (index < 0 || index >= messages.length) return messages
  77. const target = messages[index]!
  78. if (target.content.length === 0) return messages
  79. const lastTextIndex = target.content.findLastIndex((part) => part.type === "text")
  80. const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1
  81. const existing = target.content[markAt]!
  82. if (("cache" in existing && existing.cache) || budget.remaining === 0) return messages
  83. budget.remaining -= 1
  84. const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part))
  85. const next = new Message({ ...target, content: nextContent })
  86. // Single pass over `messages`, substituting the one updated entry. Long
  87. // conversations call this on every request, so avoid `.map()` here — its
  88. // closure dispatch and identity copies show up in profiling.
  89. const result = messages.slice()
  90. result[index] = next
  91. return result
  92. }
  93. const markMessages = (
  94. messages: ReadonlyArray<Message>,
  95. strategy: NonNullable<CachePolicyObject["messages"]>,
  96. hint: CacheHint,
  97. budget: Budget,
  98. ): ReadonlyArray<Message> => {
  99. if (messages.length === 0) return messages
  100. if (strategy === "latest-user-message")
  101. return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint, budget)
  102. if (strategy === "latest-assistant")
  103. return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint, budget)
  104. const start = Math.max(0, messages.length - strategy.tail)
  105. let next = messages
  106. for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint, budget)
  107. return next
  108. }
  109. const countHints = (request: LLMRequest) =>
  110. request.tools.reduce((count, tool) => count + (tool.cache === undefined ? 0 : 1), 0) +
  111. request.system.reduce((count, part) => count + (part.cache === undefined ? 0 : 1), 0) +
  112. request.messages.reduce(
  113. (count, message) =>
  114. count +
  115. message.content.reduce(
  116. (contentCount, part) => contentCount + ("cache" in part && part.cache !== undefined ? 1 : 0),
  117. 0,
  118. ),
  119. 0,
  120. )
  121. export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
  122. if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
  123. const policy = resolve(request.cache)
  124. if (!policy.tools && !policy.system && !policy.messages) return request
  125. const hint = makeHint(policy.ttlSeconds)
  126. const budget = { remaining: Math.max(0, BREAKPOINT_CAP - countHints(request)) }
  127. const tools = policy.tools ? markLastTool(request.tools, hint, budget) : request.tools
  128. const system = policy.system ? markSystemBoundaries(request.system, hint, budget) : request.system
  129. const messages = policy.messages ? markMessages(request.messages, policy.messages, hint, budget) : request.messages
  130. if (tools === request.tools && system === request.system && messages === request.messages) return request
  131. return LLMRequest.update(request, { tools, system, messages })
  132. }