cache-policy.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. import { describe, expect, test } from "bun:test"
  2. import { Effect } from "effect"
  3. import { CacheHint, LLM, Message } from "../src"
  4. import { Auth, LLMClient } from "../src/route"
  5. import { AmazonBedrock } from "../src/providers"
  6. import * as AnthropicMessages from "../src/protocols/anthropic-messages"
  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.route
  12. .with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
  13. .model({ id: "claude-sonnet-4-5" })
  14. const bedrockModel = AmazonBedrock.configure({
  15. credentials: { region: "us-east-1", accessKeyId: "fixture", secretAccessKey: "fixture" },
  16. }).model("anthropic.claude-3-5-sonnet-20241022-v2:0")
  17. const openaiModel = OpenAIChat.route
  18. .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
  19. .model({ id: "gpt-4o-mini" })
  20. const geminiModel = Gemini.route
  21. .with({
  22. endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
  23. auth: Auth.header("x-goog-api-key", "test"),
  24. })
  25. .model({ id: "gemini-2.5-flash" })
  26. describe("applyCachePolicy", () => {
  27. it.effect("undefined cache resolves to 'auto' (the recommended default)", () =>
  28. Effect.gen(function* () {
  29. const prepared = yield* LLMClient.prepare(
  30. LLM.request({
  31. model: anthropicModel,
  32. system: "You are concise.",
  33. prompt: "hi",
  34. }),
  35. )
  36. // A single system block is both the first and last boundary, so the auto
  37. // policy deduplicates it and still marks the conversation tail.
  38. expect(prepared.body).toMatchObject({
  39. system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
  40. messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
  41. })
  42. }),
  43. )
  44. it.effect("'auto' marks the last tool, first and last system parts, and final message boundary on Anthropic", () =>
  45. Effect.gen(function* () {
  46. const prepared = yield* LLMClient.prepare(
  47. LLM.request({
  48. model: anthropicModel,
  49. system: [
  50. { type: "text", text: "Base agent" },
  51. { type: "text", text: "Project instructions" },
  52. ],
  53. tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
  54. messages: [
  55. Message.user("first user"),
  56. Message.assistant("assistant reply"),
  57. Message.user("latest user message"),
  58. ],
  59. cache: "auto",
  60. }),
  61. )
  62. expect(prepared.body).toMatchObject({
  63. tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
  64. system: [
  65. { type: "text", text: "Base agent", cache_control: { type: "ephemeral" } },
  66. { type: "text", text: "Project instructions", cache_control: { type: "ephemeral" } },
  67. ],
  68. messages: [
  69. { role: "user", content: [{ type: "text", text: "first user" }] },
  70. { role: "assistant", content: [{ type: "text", text: "assistant reply" }] },
  71. {
  72. role: "user",
  73. content: [{ type: "text", text: "latest user message", cache_control: { type: "ephemeral" } }],
  74. },
  75. ],
  76. })
  77. }),
  78. )
  79. it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
  80. Effect.gen(function* () {
  81. const prepared = yield* LLMClient.prepare(
  82. LLM.request({
  83. model: openaiModel,
  84. system: "Sys",
  85. prompt: "hi",
  86. cache: "auto",
  87. }),
  88. )
  89. const body = prepared.body as { messages: Array<{ content: unknown }> }
  90. // OpenAI doesn't accept cache_control on messages — policy must skip.
  91. const flat = JSON.stringify(body)
  92. expect(flat).not.toContain("cache_control")
  93. expect(flat).not.toContain("cachePoint")
  94. }),
  95. )
  96. it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () =>
  97. Effect.gen(function* () {
  98. const prepared = yield* LLMClient.prepare(
  99. LLM.request({
  100. model: geminiModel,
  101. system: "Sys",
  102. prompt: "hi",
  103. cache: "auto",
  104. }),
  105. )
  106. const flat = JSON.stringify(prepared.body)
  107. expect(flat).not.toContain("cache_control")
  108. expect(flat).not.toContain("cachePoint")
  109. }),
  110. )
  111. it.effect("'auto' on Bedrock emits cachePoint markers in the right places", () =>
  112. Effect.gen(function* () {
  113. const prepared = yield* LLMClient.prepare(
  114. LLM.request({
  115. model: bedrockModel,
  116. system: [
  117. { type: "text", text: "Base agent" },
  118. { type: "text", text: "Project instructions" },
  119. ],
  120. tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
  121. messages: [Message.user("first user"), Message.assistant("reply"), Message.user("latest user")],
  122. cache: "auto",
  123. }),
  124. )
  125. expect(prepared.body).toMatchObject({
  126. toolConfig: {
  127. tools: [{ toolSpec: { name: "t1" } }, { cachePoint: { type: "default" } }],
  128. },
  129. system: [
  130. { text: "Base agent" },
  131. { cachePoint: { type: "default" } },
  132. { text: "Project instructions" },
  133. { cachePoint: { type: "default" } },
  134. ],
  135. messages: [
  136. { role: "user", content: [{ text: "first user" }] },
  137. { role: "assistant", content: [{ text: "reply" }] },
  138. { role: "user", content: [{ text: "latest user" }, { cachePoint: { type: "default" } }] },
  139. ],
  140. })
  141. }),
  142. )
  143. it.effect("'none' disables auto placement even when manual hints exist", () =>
  144. Effect.gen(function* () {
  145. const prepared = yield* LLMClient.prepare(
  146. LLM.request({
  147. model: anthropicModel,
  148. system: "Sys",
  149. tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
  150. prompt: "hi",
  151. cache: "none",
  152. }),
  153. )
  154. expect(prepared.body).toMatchObject({
  155. tools: [{ name: "t1", cache_control: undefined }],
  156. system: [{ type: "text", text: "Sys", cache_control: undefined }],
  157. })
  158. }),
  159. )
  160. it.effect("granular object form: tools-only marks just tools", () =>
  161. Effect.gen(function* () {
  162. const prepared = yield* LLMClient.prepare(
  163. LLM.request({
  164. model: anthropicModel,
  165. system: "Sys",
  166. tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
  167. prompt: "hi",
  168. cache: { tools: true },
  169. }),
  170. )
  171. expect(prepared.body).toMatchObject({
  172. tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
  173. system: [{ type: "text", text: "Sys", cache_control: undefined }],
  174. })
  175. }),
  176. )
  177. it.effect("auto policy preserves manual CacheHints on other parts", () =>
  178. Effect.gen(function* () {
  179. const prepared = yield* LLMClient.prepare(
  180. LLM.request({
  181. model: anthropicModel,
  182. system: [
  183. { type: "text", text: "first system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) },
  184. { type: "text", text: "last system" },
  185. ],
  186. prompt: "hi",
  187. cache: "auto",
  188. }),
  189. )
  190. const body = prepared.body as {
  191. system: Array<{ text: string; cache_control?: unknown }>
  192. messages: Array<{ content: Array<{ cache_control?: unknown }> }>
  193. }
  194. expect(body.system[0]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
  195. expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral" })
  196. expect(body.messages[0]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
  197. }),
  198. )
  199. it.effect("auto policy stays within the four-breakpoint cap when preserving manual hints", () =>
  200. Effect.gen(function* () {
  201. const request = LLM.request({
  202. model: anthropicModel,
  203. system: [
  204. { type: "text", text: "Base agent" },
  205. {
  206. type: "text",
  207. text: "Manual context",
  208. cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }),
  209. },
  210. { type: "text", text: "Project instructions" },
  211. ],
  212. tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
  213. prompt: "hi",
  214. cache: "auto",
  215. })
  216. const applied = applyCachePolicy(request)
  217. expect(applied.tools[0]?.cache).toBeDefined()
  218. expect(applied.system.map((part) => part.cache !== undefined)).toEqual([true, true, true])
  219. const tail = applied.messages[0]!.content[0]!
  220. expect("cache" in tail ? tail.cache : undefined).toBeUndefined()
  221. expect(applyCachePolicy(applied)).toBe(applied)
  222. const prepared = yield* LLMClient.prepare(request)
  223. const body = prepared.body as {
  224. tools: Array<{ cache_control?: unknown }>
  225. system: Array<{ cache_control?: unknown }>
  226. messages: Array<{ content: Array<{ cache_control?: unknown }> }>
  227. }
  228. const marked = [
  229. ...body.tools.map((tool) => tool.cache_control),
  230. ...body.system.map((part) => part.cache_control),
  231. ...body.messages.flatMap((message) => message.content.map((part) => part.cache_control)),
  232. ].filter((cache) => cache !== undefined)
  233. expect(marked).toHaveLength(4)
  234. expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
  235. expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
  236. }),
  237. )
  238. it.effect("ttlSeconds in the policy flows through to wire markers", () =>
  239. Effect.gen(function* () {
  240. const prepared = yield* LLMClient.prepare(
  241. LLM.request({
  242. model: anthropicModel,
  243. system: "Sys",
  244. prompt: "hi",
  245. cache: { system: true, ttlSeconds: 3600 },
  246. }),
  247. )
  248. expect(prepared.body).toMatchObject({
  249. system: [{ type: "text", text: "Sys", cache_control: { type: "ephemeral", ttl: "1h" } }],
  250. })
  251. }),
  252. )
  253. it.effect("messages: { tail: 2 } marks the last 2 message boundaries", () =>
  254. Effect.gen(function* () {
  255. const prepared = yield* LLMClient.prepare(
  256. LLM.request({
  257. model: anthropicModel,
  258. messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2"), Message.assistant("a2")],
  259. cache: { messages: { tail: 2 } },
  260. }),
  261. )
  262. const body = prepared.body as { messages: Array<{ content: Array<{ cache_control?: unknown }> }> }
  263. expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
  264. expect(body.messages[1]?.content[0]?.cache_control).toBeUndefined()
  265. expect(body.messages[2]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
  266. expect(body.messages[3]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
  267. }),
  268. )
  269. it.effect("'latest-assistant' marks the last assistant message", () =>
  270. Effect.gen(function* () {
  271. const prepared = yield* LLMClient.prepare(
  272. LLM.request({
  273. model: anthropicModel,
  274. messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2")],
  275. cache: { messages: "latest-assistant" },
  276. }),
  277. )
  278. const body = prepared.body as { messages: Array<{ content: Array<{ cache_control?: unknown }> }> }
  279. expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
  280. expect(body.messages[1]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
  281. expect(body.messages[2]?.content[0]?.cache_control).toBeUndefined()
  282. }),
  283. )
  284. test("returns the same request reference when policy is a no-op (pure function)", () => {
  285. const request = LLM.request({
  286. model: anthropicModel,
  287. prompt: "hi",
  288. cache: "none",
  289. })
  290. expect(applyCachePolicy(request)).toBe(request)
  291. })
  292. })