claude-code-caching.spec.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. import type { Anthropic } from "@anthropic-ai/sdk"
  2. import { ClaudeCodeHandler } from "../claude-code"
  3. import { runClaudeCode } from "../../../integrations/claude-code/run"
  4. import type { ApiHandlerOptions } from "../../../shared/api"
  5. import type { ClaudeCodeMessage } from "../../../integrations/claude-code/types"
  6. import type { ApiStreamUsageChunk } from "../../transform/stream"
  7. // Mock the runClaudeCode function
  8. vi.mock("../../../integrations/claude-code/run", () => ({
  9. runClaudeCode: vi.fn(),
  10. }))
  11. describe("ClaudeCodeHandler - Caching Support", () => {
  12. let handler: ClaudeCodeHandler
  13. const mockOptions: ApiHandlerOptions = {
  14. apiKey: "test-key",
  15. apiModelId: "claude-3-5-sonnet-20241022",
  16. claudeCodePath: "/test/path",
  17. }
  18. beforeEach(() => {
  19. handler = new ClaudeCodeHandler(mockOptions)
  20. vi.clearAllMocks()
  21. })
  22. it("should collect cache read tokens from API response", async () => {
  23. const mockStream = async function* (): AsyncGenerator<string | ClaudeCodeMessage> {
  24. // Initial system message
  25. yield {
  26. type: "system",
  27. subtype: "init",
  28. session_id: "test-session",
  29. tools: [],
  30. mcp_servers: [],
  31. apiKeySource: "user",
  32. } as ClaudeCodeMessage
  33. // Assistant message with cache tokens
  34. const message: Anthropic.Messages.Message = {
  35. id: "msg_123",
  36. type: "message",
  37. role: "assistant",
  38. model: "claude-3-5-sonnet-20241022",
  39. content: [{ type: "text", text: "Hello!", citations: [] }],
  40. usage: {
  41. input_tokens: 100,
  42. output_tokens: 50,
  43. cache_read_input_tokens: 80, // 80 tokens read from cache
  44. cache_creation_input_tokens: 20, // 20 new tokens cached
  45. },
  46. stop_reason: "end_turn",
  47. stop_sequence: null,
  48. }
  49. yield {
  50. type: "assistant",
  51. message,
  52. session_id: "test-session",
  53. } as ClaudeCodeMessage
  54. // Result with cost
  55. yield {
  56. type: "result",
  57. subtype: "success",
  58. result: "success",
  59. total_cost_usd: 0.001,
  60. is_error: false,
  61. duration_ms: 1000,
  62. duration_api_ms: 900,
  63. num_turns: 1,
  64. session_id: "test-session",
  65. } as ClaudeCodeMessage
  66. }
  67. vi.mocked(runClaudeCode).mockReturnValue(mockStream())
  68. const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }])
  69. const chunks = []
  70. for await (const chunk of stream) {
  71. chunks.push(chunk)
  72. }
  73. // Find the usage chunk
  74. const usageChunk = chunks.find((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk | undefined
  75. expect(usageChunk).toBeDefined()
  76. expect(usageChunk!.inputTokens).toBe(100)
  77. expect(usageChunk!.outputTokens).toBe(50)
  78. expect(usageChunk!.cacheReadTokens).toBe(80)
  79. expect(usageChunk!.cacheWriteTokens).toBe(20)
  80. })
  81. it("should accumulate cache tokens across multiple messages", async () => {
  82. const mockStream = async function* (): AsyncGenerator<string | ClaudeCodeMessage> {
  83. yield {
  84. type: "system",
  85. subtype: "init",
  86. session_id: "test-session",
  87. tools: [],
  88. mcp_servers: [],
  89. apiKeySource: "user",
  90. } as ClaudeCodeMessage
  91. // First message chunk
  92. const message1: Anthropic.Messages.Message = {
  93. id: "msg_1",
  94. type: "message",
  95. role: "assistant",
  96. model: "claude-3-5-sonnet-20241022",
  97. content: [{ type: "text", text: "Part 1", citations: [] }],
  98. usage: {
  99. input_tokens: 50,
  100. output_tokens: 25,
  101. cache_read_input_tokens: 40,
  102. cache_creation_input_tokens: 10,
  103. },
  104. stop_reason: null,
  105. stop_sequence: null,
  106. }
  107. yield {
  108. type: "assistant",
  109. message: message1,
  110. session_id: "test-session",
  111. } as ClaudeCodeMessage
  112. // Second message chunk
  113. const message2: Anthropic.Messages.Message = {
  114. id: "msg_2",
  115. type: "message",
  116. role: "assistant",
  117. model: "claude-3-5-sonnet-20241022",
  118. content: [{ type: "text", text: "Part 2", citations: [] }],
  119. usage: {
  120. input_tokens: 50,
  121. output_tokens: 25,
  122. cache_read_input_tokens: 30,
  123. cache_creation_input_tokens: 20,
  124. },
  125. stop_reason: "end_turn",
  126. stop_sequence: null,
  127. }
  128. yield {
  129. type: "assistant",
  130. message: message2,
  131. session_id: "test-session",
  132. } as ClaudeCodeMessage
  133. yield {
  134. type: "result",
  135. subtype: "success",
  136. result: "success",
  137. total_cost_usd: 0.002,
  138. is_error: false,
  139. duration_ms: 2000,
  140. duration_api_ms: 1800,
  141. num_turns: 1,
  142. session_id: "test-session",
  143. } as ClaudeCodeMessage
  144. }
  145. vi.mocked(runClaudeCode).mockReturnValue(mockStream())
  146. const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }])
  147. const chunks = []
  148. for await (const chunk of stream) {
  149. chunks.push(chunk)
  150. }
  151. const usageChunk = chunks.find((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk | undefined
  152. expect(usageChunk).toBeDefined()
  153. expect(usageChunk!.inputTokens).toBe(100) // 50 + 50
  154. expect(usageChunk!.outputTokens).toBe(50) // 25 + 25
  155. expect(usageChunk!.cacheReadTokens).toBe(70) // 40 + 30
  156. expect(usageChunk!.cacheWriteTokens).toBe(30) // 10 + 20
  157. })
  158. it("should handle missing cache token fields gracefully", async () => {
  159. const mockStream = async function* (): AsyncGenerator<string | ClaudeCodeMessage> {
  160. yield {
  161. type: "system",
  162. subtype: "init",
  163. session_id: "test-session",
  164. tools: [],
  165. mcp_servers: [],
  166. apiKeySource: "user",
  167. } as ClaudeCodeMessage
  168. // Message without cache tokens
  169. const message: Anthropic.Messages.Message = {
  170. id: "msg_123",
  171. type: "message",
  172. role: "assistant",
  173. model: "claude-3-5-sonnet-20241022",
  174. content: [{ type: "text", text: "Hello!", citations: [] }],
  175. usage: {
  176. input_tokens: 100,
  177. output_tokens: 50,
  178. cache_read_input_tokens: null,
  179. cache_creation_input_tokens: null,
  180. },
  181. stop_reason: "end_turn",
  182. stop_sequence: null,
  183. }
  184. yield {
  185. type: "assistant",
  186. message,
  187. session_id: "test-session",
  188. } as ClaudeCodeMessage
  189. yield {
  190. type: "result",
  191. subtype: "success",
  192. result: "success",
  193. total_cost_usd: 0.001,
  194. is_error: false,
  195. duration_ms: 1000,
  196. duration_api_ms: 900,
  197. num_turns: 1,
  198. session_id: "test-session",
  199. } as ClaudeCodeMessage
  200. }
  201. vi.mocked(runClaudeCode).mockReturnValue(mockStream())
  202. const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }])
  203. const chunks = []
  204. for await (const chunk of stream) {
  205. chunks.push(chunk)
  206. }
  207. const usageChunk = chunks.find((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk | undefined
  208. expect(usageChunk).toBeDefined()
  209. expect(usageChunk!.inputTokens).toBe(100)
  210. expect(usageChunk!.outputTokens).toBe(50)
  211. expect(usageChunk!.cacheReadTokens).toBe(0)
  212. expect(usageChunk!.cacheWriteTokens).toBe(0)
  213. })
  214. it("should report zero cost for subscription usage", async () => {
  215. const mockStream = async function* (): AsyncGenerator<string | ClaudeCodeMessage> {
  216. // Subscription usage has apiKeySource: "none"
  217. yield {
  218. type: "system",
  219. subtype: "init",
  220. session_id: "test-session",
  221. tools: [],
  222. mcp_servers: [],
  223. apiKeySource: "none",
  224. } as ClaudeCodeMessage
  225. const message: Anthropic.Messages.Message = {
  226. id: "msg_123",
  227. type: "message",
  228. role: "assistant",
  229. model: "claude-3-5-sonnet-20241022",
  230. content: [{ type: "text", text: "Hello!", citations: [] }],
  231. usage: {
  232. input_tokens: 100,
  233. output_tokens: 50,
  234. cache_read_input_tokens: 80,
  235. cache_creation_input_tokens: 20,
  236. },
  237. stop_reason: "end_turn",
  238. stop_sequence: null,
  239. }
  240. yield {
  241. type: "assistant",
  242. message,
  243. session_id: "test-session",
  244. } as ClaudeCodeMessage
  245. yield {
  246. type: "result",
  247. subtype: "success",
  248. result: "success",
  249. total_cost_usd: 0.001, // This should be ignored for subscription usage
  250. is_error: false,
  251. duration_ms: 1000,
  252. duration_api_ms: 900,
  253. num_turns: 1,
  254. session_id: "test-session",
  255. } as ClaudeCodeMessage
  256. }
  257. vi.mocked(runClaudeCode).mockReturnValue(mockStream())
  258. const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }])
  259. const chunks = []
  260. for await (const chunk of stream) {
  261. chunks.push(chunk)
  262. }
  263. const usageChunk = chunks.find((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk | undefined
  264. expect(usageChunk).toBeDefined()
  265. expect(usageChunk!.totalCost).toBe(0) // Should be 0 for subscription usage
  266. })
  267. })