cache-policy.test.ts 12 KB

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