cache-policy.test.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  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. // No explicit cache field → auto policy fires → last system part + latest
  37. // user message both get cache_control markers.
  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, last system part, and latest user message on Anthropic", () =>
  45. Effect.gen(function* () {
  46. const prepared = yield* LLMClient.prepare(
  47. LLM.request({
  48. model: anthropicModel,
  49. system: "Sys A",
  50. tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
  51. messages: [
  52. Message.user("first user"),
  53. Message.assistant("assistant reply"),
  54. Message.user("latest user message"),
  55. ],
  56. cache: "auto",
  57. }),
  58. )
  59. expect(prepared.body).toMatchObject({
  60. tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
  61. system: [{ type: "text", text: "Sys A", cache_control: { type: "ephemeral" } }],
  62. messages: [
  63. { role: "user", content: [{ type: "text", text: "first user" }] },
  64. { role: "assistant", content: [{ type: "text", text: "assistant reply" }] },
  65. {
  66. role: "user",
  67. content: [{ type: "text", text: "latest user message", cache_control: { type: "ephemeral" } }],
  68. },
  69. ],
  70. })
  71. }),
  72. )
  73. it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
  74. Effect.gen(function* () {
  75. const prepared = yield* LLMClient.prepare(
  76. LLM.request({
  77. model: openaiModel,
  78. system: "Sys",
  79. prompt: "hi",
  80. cache: "auto",
  81. }),
  82. )
  83. const body = prepared.body as { messages: Array<{ content: unknown }> }
  84. // OpenAI doesn't accept cache_control on messages — policy must skip.
  85. const flat = JSON.stringify(body)
  86. expect(flat).not.toContain("cache_control")
  87. expect(flat).not.toContain("cachePoint")
  88. }),
  89. )
  90. it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () =>
  91. Effect.gen(function* () {
  92. const prepared = yield* LLMClient.prepare(
  93. LLM.request({
  94. model: geminiModel,
  95. system: "Sys",
  96. prompt: "hi",
  97. cache: "auto",
  98. }),
  99. )
  100. const flat = JSON.stringify(prepared.body)
  101. expect(flat).not.toContain("cache_control")
  102. expect(flat).not.toContain("cachePoint")
  103. }),
  104. )
  105. it.effect("'auto' on Bedrock emits cachePoint markers in the right places", () =>
  106. Effect.gen(function* () {
  107. const prepared = yield* LLMClient.prepare(
  108. LLM.request({
  109. model: bedrockModel,
  110. system: "Sys",
  111. tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
  112. messages: [Message.user("first user"), Message.assistant("reply"), Message.user("latest user")],
  113. cache: "auto",
  114. }),
  115. )
  116. expect(prepared.body).toMatchObject({
  117. toolConfig: {
  118. tools: [{ toolSpec: { name: "t1" } }, { cachePoint: { type: "default" } }],
  119. },
  120. system: [{ text: "Sys" }, { cachePoint: { type: "default" } }],
  121. messages: [
  122. { role: "user", content: [{ text: "first user" }] },
  123. { role: "assistant", content: [{ text: "reply" }] },
  124. { role: "user", content: [{ text: "latest user" }, { cachePoint: { type: "default" } }] },
  125. ],
  126. })
  127. }),
  128. )
  129. it.effect("'none' disables auto placement even when manual hints exist", () =>
  130. Effect.gen(function* () {
  131. const prepared = yield* LLMClient.prepare(
  132. LLM.request({
  133. model: anthropicModel,
  134. system: "Sys",
  135. tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
  136. prompt: "hi",
  137. cache: "none",
  138. }),
  139. )
  140. expect(prepared.body).toMatchObject({
  141. tools: [{ name: "t1", cache_control: undefined }],
  142. system: [{ type: "text", text: "Sys", cache_control: undefined }],
  143. })
  144. }),
  145. )
  146. it.effect("granular object form: tools-only marks just tools", () =>
  147. Effect.gen(function* () {
  148. const prepared = yield* LLMClient.prepare(
  149. LLM.request({
  150. model: anthropicModel,
  151. system: "Sys",
  152. tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
  153. prompt: "hi",
  154. cache: { tools: true },
  155. }),
  156. )
  157. expect(prepared.body).toMatchObject({
  158. tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
  159. system: [{ type: "text", text: "Sys", cache_control: undefined }],
  160. })
  161. }),
  162. )
  163. it.effect("auto policy preserves manual CacheHints on other parts", () =>
  164. Effect.gen(function* () {
  165. const prepared = yield* LLMClient.prepare(
  166. LLM.request({
  167. model: anthropicModel,
  168. system: [
  169. { type: "text", text: "first system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) },
  170. { type: "text", text: "last system" },
  171. ],
  172. prompt: "hi",
  173. cache: "auto",
  174. }),
  175. )
  176. const body = prepared.body as { system: Array<{ text: string; cache_control?: unknown }> }
  177. expect(body.system[0]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
  178. expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral" })
  179. }),
  180. )
  181. it.effect("ttlSeconds in the policy flows through to wire markers", () =>
  182. Effect.gen(function* () {
  183. const prepared = yield* LLMClient.prepare(
  184. LLM.request({
  185. model: anthropicModel,
  186. system: "Sys",
  187. prompt: "hi",
  188. cache: { system: true, ttlSeconds: 3600 },
  189. }),
  190. )
  191. expect(prepared.body).toMatchObject({
  192. system: [{ type: "text", text: "Sys", cache_control: { type: "ephemeral", ttl: "1h" } }],
  193. })
  194. }),
  195. )
  196. it.effect("messages: { tail: 2 } marks the last 2 message boundaries", () =>
  197. Effect.gen(function* () {
  198. const prepared = yield* LLMClient.prepare(
  199. LLM.request({
  200. model: anthropicModel,
  201. messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2"), Message.assistant("a2")],
  202. cache: { messages: { tail: 2 } },
  203. }),
  204. )
  205. const body = prepared.body as { messages: Array<{ content: Array<{ cache_control?: unknown }> }> }
  206. expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
  207. expect(body.messages[1]?.content[0]?.cache_control).toBeUndefined()
  208. expect(body.messages[2]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
  209. expect(body.messages[3]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
  210. }),
  211. )
  212. it.effect("'latest-assistant' marks the last assistant message", () =>
  213. Effect.gen(function* () {
  214. const prepared = yield* LLMClient.prepare(
  215. LLM.request({
  216. model: anthropicModel,
  217. messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2")],
  218. cache: { messages: "latest-assistant" },
  219. }),
  220. )
  221. const body = prepared.body as { messages: Array<{ content: Array<{ cache_control?: unknown }> }> }
  222. expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
  223. expect(body.messages[1]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
  224. expect(body.messages[2]?.content[0]?.cache_control).toBeUndefined()
  225. }),
  226. )
  227. test("returns the same request reference when policy is a no-op (pure function)", () => {
  228. const request = LLM.request({
  229. model: anthropicModel,
  230. prompt: "hi",
  231. cache: "none",
  232. })
  233. expect(applyCachePolicy(request)).toBe(request)
  234. })
  235. })