cache-policy.test.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. import { describe, expect, test } from "bun:test"
  2. import { Effect } from "effect"
  3. import { CacheHint, LLM, Message } from "../src"
  4. import { LLMClient } from "../src/route"
  5. import * as AnthropicMessages from "../src/protocols/anthropic-messages"
  6. import * as BedrockConverse from "../src/protocols/bedrock-converse"
  7. import * as Gemini from "../src/protocols/gemini"
  8. import * as OpenAIChat from "../src/protocols/openai-chat"
  9. import { applyCachePolicy } from "../src/cache-policy"
  10. import { it } from "./lib/effect"
  11. const anthropicModel = AnthropicMessages.model({
  12. id: "claude-sonnet-4-5",
  13. baseURL: "https://api.anthropic.test/v1/",
  14. headers: { "x-api-key": "test" },
  15. })
  16. const bedrockModel = BedrockConverse.model({
  17. id: "anthropic.claude-3-5-sonnet-20241022-v2:0",
  18. credentials: { region: "us-east-1", accessKeyId: "fixture", secretAccessKey: "fixture" },
  19. })
  20. const openaiModel = OpenAIChat.model({
  21. id: "gpt-4o-mini",
  22. baseURL: "https://api.openai.test/v1/",
  23. headers: { authorization: "Bearer test" },
  24. })
  25. const geminiModel = Gemini.model({
  26. id: "gemini-2.5-flash",
  27. baseURL: "https://generativelanguage.test/v1beta/",
  28. headers: { "x-goog-api-key": "test" },
  29. })
  30. describe("applyCachePolicy", () => {
  31. it.effect("undefined cache resolves to 'auto' (the recommended default)", () =>
  32. Effect.gen(function* () {
  33. const prepared = yield* LLMClient.prepare(
  34. LLM.request({
  35. model: anthropicModel,
  36. system: "You are concise.",
  37. prompt: "hi",
  38. }),
  39. )
  40. // No explicit cache field → auto policy fires → last system part + latest
  41. // user message both get cache_control markers.
  42. expect(prepared.body).toMatchObject({
  43. system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
  44. messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
  45. })
  46. }),
  47. )
  48. it.effect("'auto' marks the last tool, last system part, and latest user message on Anthropic", () =>
  49. Effect.gen(function* () {
  50. const prepared = yield* LLMClient.prepare(
  51. LLM.request({
  52. model: anthropicModel,
  53. system: "Sys A",
  54. tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
  55. messages: [
  56. Message.user("first user"),
  57. Message.assistant("assistant reply"),
  58. Message.user("latest user message"),
  59. ],
  60. cache: "auto",
  61. }),
  62. )
  63. expect(prepared.body).toMatchObject({
  64. tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
  65. system: [{ type: "text", text: "Sys A", cache_control: { type: "ephemeral" } }],
  66. messages: [
  67. { role: "user", content: [{ type: "text", text: "first user" }] },
  68. { role: "assistant", content: [{ type: "text", text: "assistant reply" }] },
  69. {
  70. role: "user",
  71. content: [{ type: "text", text: "latest user message", cache_control: { type: "ephemeral" } }],
  72. },
  73. ],
  74. })
  75. }),
  76. )
  77. it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
  78. Effect.gen(function* () {
  79. const prepared = yield* LLMClient.prepare(
  80. LLM.request({
  81. model: openaiModel,
  82. system: "Sys",
  83. prompt: "hi",
  84. cache: "auto",
  85. }),
  86. )
  87. const body = prepared.body as { messages: Array<{ content: unknown }> }
  88. // OpenAI doesn't accept cache_control on messages — policy must skip.
  89. const flat = JSON.stringify(body)
  90. expect(flat).not.toContain("cache_control")
  91. expect(flat).not.toContain("cachePoint")
  92. }),
  93. )
  94. it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () =>
  95. Effect.gen(function* () {
  96. const prepared = yield* LLMClient.prepare(
  97. LLM.request({
  98. model: geminiModel,
  99. system: "Sys",
  100. prompt: "hi",
  101. cache: "auto",
  102. }),
  103. )
  104. const flat = JSON.stringify(prepared.body)
  105. expect(flat).not.toContain("cache_control")
  106. expect(flat).not.toContain("cachePoint")
  107. }),
  108. )
  109. it.effect("'auto' on Bedrock emits cachePoint markers in the right places", () =>
  110. Effect.gen(function* () {
  111. const prepared = yield* LLMClient.prepare(
  112. LLM.request({
  113. model: bedrockModel,
  114. system: "Sys",
  115. tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
  116. messages: [Message.user("first user"), Message.assistant("reply"), Message.user("latest user")],
  117. cache: "auto",
  118. }),
  119. )
  120. expect(prepared.body).toMatchObject({
  121. toolConfig: {
  122. tools: [{ toolSpec: { name: "t1" } }, { cachePoint: { type: "default" } }],
  123. },
  124. system: [{ text: "Sys" }, { cachePoint: { type: "default" } }],
  125. messages: [
  126. { role: "user", content: [{ text: "first user" }] },
  127. { role: "assistant", content: [{ text: "reply" }] },
  128. { role: "user", content: [{ text: "latest user" }, { cachePoint: { type: "default" } }] },
  129. ],
  130. })
  131. }),
  132. )
  133. it.effect("'none' disables auto placement even when manual hints exist", () =>
  134. Effect.gen(function* () {
  135. const prepared = yield* LLMClient.prepare(
  136. LLM.request({
  137. model: anthropicModel,
  138. system: "Sys",
  139. tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
  140. prompt: "hi",
  141. cache: "none",
  142. }),
  143. )
  144. expect(prepared.body).toMatchObject({
  145. tools: [{ name: "t1", cache_control: undefined }],
  146. system: [{ type: "text", text: "Sys", cache_control: undefined }],
  147. })
  148. }),
  149. )
  150. it.effect("granular object form: tools-only marks just tools", () =>
  151. Effect.gen(function* () {
  152. const prepared = yield* LLMClient.prepare(
  153. LLM.request({
  154. model: anthropicModel,
  155. system: "Sys",
  156. tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
  157. prompt: "hi",
  158. cache: { tools: true },
  159. }),
  160. )
  161. expect(prepared.body).toMatchObject({
  162. tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
  163. system: [{ type: "text", text: "Sys", cache_control: undefined }],
  164. })
  165. }),
  166. )
  167. it.effect("auto policy preserves manual CacheHints on other parts", () =>
  168. Effect.gen(function* () {
  169. const prepared = yield* LLMClient.prepare(
  170. LLM.request({
  171. model: anthropicModel,
  172. system: [
  173. { type: "text", text: "first system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) },
  174. { type: "text", text: "last system" },
  175. ],
  176. prompt: "hi",
  177. cache: "auto",
  178. }),
  179. )
  180. const body = prepared.body as { system: Array<{ text: string; cache_control?: unknown }> }
  181. expect(body.system[0]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
  182. expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral" })
  183. }),
  184. )
  185. it.effect("ttlSeconds in the policy flows through to wire markers", () =>
  186. Effect.gen(function* () {
  187. const prepared = yield* LLMClient.prepare(
  188. LLM.request({
  189. model: anthropicModel,
  190. system: "Sys",
  191. prompt: "hi",
  192. cache: { system: true, ttlSeconds: 3600 },
  193. }),
  194. )
  195. expect(prepared.body).toMatchObject({
  196. system: [{ type: "text", text: "Sys", cache_control: { type: "ephemeral", ttl: "1h" } }],
  197. })
  198. }),
  199. )
  200. it.effect("messages: { tail: 2 } marks the last 2 message boundaries", () =>
  201. Effect.gen(function* () {
  202. const prepared = yield* LLMClient.prepare(
  203. LLM.request({
  204. model: anthropicModel,
  205. messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2"), Message.assistant("a2")],
  206. cache: { messages: { tail: 2 } },
  207. }),
  208. )
  209. const body = prepared.body as { messages: Array<{ content: Array<{ cache_control?: unknown }> }> }
  210. expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
  211. expect(body.messages[1]?.content[0]?.cache_control).toBeUndefined()
  212. expect(body.messages[2]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
  213. expect(body.messages[3]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
  214. }),
  215. )
  216. it.effect("'latest-assistant' marks the last assistant message", () =>
  217. Effect.gen(function* () {
  218. const prepared = yield* LLMClient.prepare(
  219. LLM.request({
  220. model: anthropicModel,
  221. messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2")],
  222. cache: { messages: "latest-assistant" },
  223. }),
  224. )
  225. const body = prepared.body as { messages: Array<{ content: Array<{ cache_control?: unknown }> }> }
  226. expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
  227. expect(body.messages[1]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
  228. expect(body.messages[2]?.content[0]?.cache_control).toBeUndefined()
  229. }),
  230. )
  231. test("returns the same request reference when policy is a no-op (pure function)", () => {
  232. const request = LLM.request({
  233. model: anthropicModel,
  234. prompt: "hi",
  235. cache: "none",
  236. })
  237. expect(applyCachePolicy(request)).toBe(request)
  238. })
  239. })