anthropic-messages.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538
  1. import { describe, expect } from "bun:test"
  2. import { Effect } from "effect"
  3. import { CacheHint, LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
  4. import { Auth, LLMClient } from "../../src/route"
  5. import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
  6. import { it } from "../lib/effect"
  7. import { fixedResponse } from "../lib/http"
  8. import { sseEvents } from "../lib/sse"
  9. const model = AnthropicMessages.route
  10. .with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
  11. .model({ id: "claude-sonnet-4-5" })
  12. const request = LLM.request({
  13. id: "req_1",
  14. model,
  15. system: { type: "text", text: "You are concise.", cache: new CacheHint({ type: "ephemeral" }) },
  16. prompt: "Say hello.",
  17. // This fixture predates the `cache: "auto"` default; pin the policy off so
  18. // existing wire-shape assertions only see the manual hint on the system part.
  19. cache: "none",
  20. generation: { maxTokens: 20, temperature: 0 },
  21. })
  22. describe("Anthropic Messages route", () => {
  23. it.effect("prepares Anthropic Messages target", () =>
  24. Effect.gen(function* () {
  25. const prepared = yield* LLMClient.prepare(request)
  26. expect(prepared.body).toEqual({
  27. model: "claude-sonnet-4-5",
  28. system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
  29. messages: [{ role: "user", content: [{ type: "text", text: "Say hello." }] }],
  30. stream: true,
  31. max_tokens: 20,
  32. temperature: 0,
  33. })
  34. }),
  35. )
  36. it.effect("prepares tool call and tool result messages", () =>
  37. Effect.gen(function* () {
  38. const prepared = yield* LLMClient.prepare(
  39. LLM.request({
  40. id: "req_tool_result",
  41. model,
  42. messages: [
  43. Message.user("What is the weather?"),
  44. Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
  45. Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
  46. ],
  47. cache: "none",
  48. }),
  49. )
  50. expect(prepared.body).toEqual({
  51. model: "claude-sonnet-4-5",
  52. messages: [
  53. { role: "user", content: [{ type: "text", text: "What is the weather?" }] },
  54. {
  55. role: "assistant",
  56. content: [{ type: "tool_use", id: "call_1", name: "lookup", input: { query: "weather" } }],
  57. },
  58. { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"forecast":"sunny"}' }] },
  59. ],
  60. stream: true,
  61. max_tokens: 4096,
  62. })
  63. }),
  64. )
  65. it.effect("lowers preserved Anthropic reasoning signature metadata", () =>
  66. Effect.gen(function* () {
  67. const prepared = yield* LLMClient.prepare(
  68. LLM.request({
  69. model,
  70. messages: [
  71. Message.assistant([
  72. { type: "reasoning", text: "thinking", providerMetadata: { anthropic: { signature: "sig_1" } } },
  73. ]),
  74. ],
  75. }),
  76. )
  77. expect(prepared.body).toMatchObject({
  78. messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "thinking", signature: "sig_1" }] }],
  79. })
  80. }),
  81. )
  82. it.effect("parses text, reasoning, and usage stream fixtures", () =>
  83. Effect.gen(function* () {
  84. const body = sseEvents(
  85. { type: "message_start", message: { usage: { input_tokens: 5, cache_read_input_tokens: 1 } } },
  86. { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
  87. { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } },
  88. { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "!" } },
  89. { type: "content_block_stop", index: 0 },
  90. { type: "content_block_start", index: 1, content_block: { type: "thinking", thinking: "" } },
  91. { type: "content_block_delta", index: 1, delta: { type: "thinking_delta", thinking: "thinking" } },
  92. { type: "content_block_delta", index: 1, delta: { type: "signature_delta", signature: "sig_1" } },
  93. { type: "content_block_stop", index: 1 },
  94. {
  95. type: "message_delta",
  96. delta: { stop_reason: "end_turn", stop_sequence: "\n\nHuman:" },
  97. usage: { output_tokens: 2 },
  98. },
  99. { type: "message_stop" },
  100. )
  101. const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
  102. expect(response.text).toBe("Hello!")
  103. expect(response.reasoning).toBe("thinking")
  104. expect(response.usage).toMatchObject({
  105. inputTokens: 6,
  106. outputTokens: 2,
  107. nonCachedInputTokens: 5,
  108. cacheReadInputTokens: 1,
  109. totalTokens: 8,
  110. })
  111. expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
  112. providerMetadata: { anthropic: { signature: "sig_1" } },
  113. })
  114. expect(response.events.at(-1)).toMatchObject({
  115. type: "finish",
  116. reason: "stop",
  117. providerMetadata: { anthropic: { stopSequence: "\n\nHuman:" } },
  118. })
  119. }),
  120. )
  121. it.effect("assembles streamed tool call input", () =>
  122. Effect.gen(function* () {
  123. const body = sseEvents(
  124. { type: "message_start", message: { usage: { input_tokens: 5 } } },
  125. { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "call_1", name: "lookup" } },
  126. { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '{"query"' } },
  127. { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: ':"weather"}' } },
  128. { type: "content_block_stop", index: 0 },
  129. { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
  130. )
  131. const response = yield* LLMClient.generate(
  132. LLM.updateRequest(request, {
  133. tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
  134. }),
  135. ).pipe(Effect.provide(fixedResponse(body)))
  136. const usage = new Usage({
  137. inputTokens: 5,
  138. outputTokens: 1,
  139. nonCachedInputTokens: 5,
  140. cacheReadInputTokens: undefined,
  141. cacheWriteInputTokens: undefined,
  142. totalTokens: 6,
  143. providerMetadata: { anthropic: { input_tokens: 5, output_tokens: 1 } },
  144. })
  145. expect(response.toolCalls).toEqual([
  146. {
  147. type: "tool-call",
  148. id: "call_1",
  149. name: "lookup",
  150. input: { query: "weather" },
  151. providerExecuted: undefined,
  152. providerMetadata: undefined,
  153. },
  154. ])
  155. expect(response.events).toEqual([
  156. { type: "step-start", index: 0 },
  157. { type: "tool-input-start", id: "call_1", name: "lookup" },
  158. { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
  159. { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
  160. { type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
  161. {
  162. type: "tool-call",
  163. id: "call_1",
  164. name: "lookup",
  165. input: { query: "weather" },
  166. providerExecuted: undefined,
  167. providerMetadata: undefined,
  168. },
  169. { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
  170. {
  171. type: "finish",
  172. reason: "tool-calls",
  173. providerMetadata: undefined,
  174. usage,
  175. },
  176. ])
  177. }),
  178. )
  179. it.effect("emits provider-error events for mid-stream provider errors", () =>
  180. Effect.gen(function* () {
  181. const response = yield* LLMClient.generate(request).pipe(
  182. Effect.provide(
  183. fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "Overloaded" } })),
  184. ),
  185. )
  186. expect(response.events).toEqual([{ type: "provider-error", message: "Overloaded" }])
  187. }),
  188. )
  189. it.effect("fails HTTP provider errors before stream parsing", () =>
  190. Effect.gen(function* () {
  191. const error = yield* LLMClient.generate(request).pipe(
  192. Effect.provide(
  193. fixedResponse('{"type":"error","error":{"type":"invalid_request_error","message":"Bad request"}}', {
  194. status: 400,
  195. headers: { "content-type": "application/json" },
  196. }),
  197. ),
  198. Effect.flip,
  199. )
  200. expect(error).toBeInstanceOf(LLMError)
  201. expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
  202. expect(error.message).toContain("HTTP 400")
  203. }),
  204. )
  205. it.effect("decodes server_tool_use + web_search_tool_result as provider-executed events", () =>
  206. Effect.gen(function* () {
  207. const body = sseEvents(
  208. { type: "message_start", message: { usage: { input_tokens: 5 } } },
  209. {
  210. type: "content_block_start",
  211. index: 0,
  212. content_block: { type: "server_tool_use", id: "srvtoolu_abc", name: "web_search" },
  213. },
  214. {
  215. type: "content_block_delta",
  216. index: 0,
  217. delta: { type: "input_json_delta", partial_json: '{"query":"effect 4"}' },
  218. },
  219. { type: "content_block_stop", index: 0 },
  220. {
  221. type: "content_block_start",
  222. index: 1,
  223. content_block: {
  224. type: "web_search_tool_result",
  225. tool_use_id: "srvtoolu_abc",
  226. content: [{ type: "web_search_result", url: "https://example.com", title: "Example" }],
  227. },
  228. },
  229. { type: "content_block_stop", index: 1 },
  230. { type: "content_block_start", index: 2, content_block: { type: "text", text: "" } },
  231. { type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Found it." } },
  232. { type: "content_block_stop", index: 2 },
  233. { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
  234. )
  235. const response = yield* LLMClient.generate(
  236. LLM.updateRequest(request, {
  237. tools: [{ name: "web_search", description: "Web search", inputSchema: { type: "object" } }],
  238. }),
  239. ).pipe(Effect.provide(fixedResponse(body)))
  240. const toolCall = response.events.find((event) => event.type === "tool-call")
  241. expect(toolCall).toEqual({
  242. type: "tool-call",
  243. id: "srvtoolu_abc",
  244. name: "web_search",
  245. input: { query: "effect 4" },
  246. providerExecuted: true,
  247. })
  248. const toolResult = response.events.find((event) => event.type === "tool-result")
  249. expect(toolResult).toEqual({
  250. type: "tool-result",
  251. id: "srvtoolu_abc",
  252. name: "web_search",
  253. result: { type: "json", value: [{ type: "web_search_result", url: "https://example.com", title: "Example" }] },
  254. providerExecuted: true,
  255. providerMetadata: { anthropic: { blockType: "web_search_tool_result" } },
  256. })
  257. expect(response.text).toBe("Found it.")
  258. expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
  259. }),
  260. )
  261. it.effect("decodes web_search_tool_result_error as provider-executed error result", () =>
  262. Effect.gen(function* () {
  263. const body = sseEvents(
  264. { type: "message_start", message: { usage: { input_tokens: 5 } } },
  265. {
  266. type: "content_block_start",
  267. index: 0,
  268. content_block: { type: "server_tool_use", id: "srvtoolu_x", name: "web_search" },
  269. },
  270. { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '{"query":"q"}' } },
  271. { type: "content_block_stop", index: 0 },
  272. {
  273. type: "content_block_start",
  274. index: 1,
  275. content_block: {
  276. type: "web_search_tool_result",
  277. tool_use_id: "srvtoolu_x",
  278. content: { type: "web_search_tool_result_error", error_code: "max_uses_exceeded" },
  279. },
  280. },
  281. { type: "content_block_stop", index: 1 },
  282. { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
  283. )
  284. const response = yield* LLMClient.generate(
  285. LLM.updateRequest(request, {
  286. tools: [{ name: "web_search", description: "Web search", inputSchema: { type: "object" } }],
  287. }),
  288. ).pipe(Effect.provide(fixedResponse(body)))
  289. const toolResult = response.events.find((event) => event.type === "tool-result")
  290. expect(toolResult).toMatchObject({
  291. type: "tool-result",
  292. id: "srvtoolu_x",
  293. name: "web_search",
  294. result: { type: "error" },
  295. providerExecuted: true,
  296. })
  297. }),
  298. )
  299. it.effect("round-trips provider-executed assistant content into server tool blocks", () =>
  300. Effect.gen(function* () {
  301. const prepared = yield* LLMClient.prepare(
  302. LLM.request({
  303. id: "req_round_trip",
  304. model,
  305. messages: [
  306. Message.user("Search for something."),
  307. Message.assistant([
  308. {
  309. type: "tool-call",
  310. id: "srvtoolu_abc",
  311. name: "web_search",
  312. input: { query: "effect 4" },
  313. providerExecuted: true,
  314. },
  315. {
  316. type: "tool-result",
  317. id: "srvtoolu_abc",
  318. name: "web_search",
  319. result: { type: "json", value: [{ url: "https://example.com" }] },
  320. providerExecuted: true,
  321. },
  322. { type: "text", text: "Found it." },
  323. ]),
  324. Message.user("Thanks."),
  325. ],
  326. }),
  327. )
  328. expect(prepared.body).toMatchObject({
  329. messages: [
  330. { role: "user", content: [{ type: "text", text: "Search for something." }] },
  331. {
  332. role: "assistant",
  333. content: [
  334. { type: "server_tool_use", id: "srvtoolu_abc", name: "web_search", input: { query: "effect 4" } },
  335. {
  336. type: "web_search_tool_result",
  337. tool_use_id: "srvtoolu_abc",
  338. content: [{ url: "https://example.com" }],
  339. },
  340. { type: "text", text: "Found it." },
  341. ],
  342. },
  343. { role: "user", content: [{ type: "text", text: "Thanks." }] },
  344. ],
  345. })
  346. }),
  347. )
  348. it.effect("rejects round-trip for unknown server tool names", () =>
  349. Effect.gen(function* () {
  350. const error = yield* LLMClient.prepare(
  351. LLM.request({
  352. id: "req_unknown_server_tool",
  353. model,
  354. messages: [
  355. Message.assistant([
  356. {
  357. type: "tool-result",
  358. id: "srvtoolu_abc",
  359. name: "future_server_tool",
  360. result: { type: "json", value: {} },
  361. providerExecuted: true,
  362. },
  363. ]),
  364. ],
  365. }),
  366. ).pipe(Effect.flip)
  367. expect(error.message).toContain("future_server_tool")
  368. }),
  369. )
  370. it.effect("rejects unsupported user media content", () =>
  371. Effect.gen(function* () {
  372. const error = yield* LLMClient.prepare(
  373. LLM.request({
  374. id: "req_media",
  375. model,
  376. messages: [Message.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })],
  377. }),
  378. ).pipe(Effect.flip)
  379. expect(error.message).toContain("Anthropic Messages user messages only support text content for now")
  380. }),
  381. )
  382. it.effect("maps ttlSeconds >= 3600 to cache_control ttl: '1h'", () =>
  383. Effect.gen(function* () {
  384. const prepared = yield* LLMClient.prepare(
  385. LLM.request({
  386. model,
  387. system: { type: "text", text: "system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) },
  388. prompt: "hi",
  389. }),
  390. )
  391. expect(prepared.body).toMatchObject({
  392. system: [{ type: "text", text: "system", cache_control: { type: "ephemeral", ttl: "1h" } }],
  393. })
  394. }),
  395. )
  396. it.effect("emits cache_control on tool definitions and tool-result blocks", () =>
  397. Effect.gen(function* () {
  398. const prepared = yield* LLMClient.prepare(
  399. LLM.request({
  400. model,
  401. tools: [
  402. {
  403. name: "lookup",
  404. description: "lookup tool",
  405. inputSchema: { type: "object", properties: {} },
  406. cache: new CacheHint({ type: "ephemeral" }),
  407. },
  408. ],
  409. messages: [
  410. Message.user("What's the weather?"),
  411. Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
  412. Message.tool({
  413. id: "call_1",
  414. name: "lookup",
  415. result: { temp: 72 },
  416. cache: new CacheHint({ type: "ephemeral" }),
  417. }),
  418. ],
  419. }),
  420. )
  421. expect(prepared.body).toMatchObject({
  422. tools: [{ name: "lookup", cache_control: { type: "ephemeral" } }],
  423. messages: [
  424. { role: "user", content: [{ type: "text", text: "What's the weather?" }] },
  425. { role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup" }] },
  426. {
  427. role: "user",
  428. content: [{ type: "tool_result", tool_use_id: "call_1", cache_control: { type: "ephemeral" } }],
  429. },
  430. ],
  431. })
  432. }),
  433. )
  434. it.effect("drops cache_control breakpoints past the 4-per-request cap", () =>
  435. Effect.gen(function* () {
  436. const hint = new CacheHint({ type: "ephemeral" })
  437. const prepared = yield* LLMClient.prepare(
  438. LLM.request({
  439. model,
  440. system: [
  441. { type: "text", text: "a", cache: hint },
  442. { type: "text", text: "b", cache: hint },
  443. { type: "text", text: "c", cache: hint },
  444. { type: "text", text: "d", cache: hint },
  445. { type: "text", text: "e", cache: hint },
  446. { type: "text", text: "f", cache: hint },
  447. ],
  448. prompt: "hi",
  449. }),
  450. )
  451. const system = (prepared.body as { system: Array<{ cache_control?: unknown }> }).system
  452. const marked = system.filter((part) => part.cache_control !== undefined)
  453. expect(marked).toHaveLength(4)
  454. expect(system[4]?.cache_control).toBeUndefined()
  455. expect(system[5]?.cache_control).toBeUndefined()
  456. }),
  457. )
  458. it.effect("spends breakpoint budget on tools before system before messages", () =>
  459. Effect.gen(function* () {
  460. const hint = new CacheHint({ type: "ephemeral" })
  461. const prepared = yield* LLMClient.prepare(
  462. LLM.request({
  463. model,
  464. tools: [
  465. {
  466. name: "t1",
  467. description: "t1",
  468. inputSchema: { type: "object", properties: {} },
  469. cache: hint,
  470. },
  471. {
  472. name: "t2",
  473. description: "t2",
  474. inputSchema: { type: "object", properties: {} },
  475. cache: hint,
  476. },
  477. {
  478. name: "t3",
  479. description: "t3",
  480. inputSchema: { type: "object", properties: {} },
  481. cache: hint,
  482. },
  483. {
  484. name: "t4",
  485. description: "t4",
  486. inputSchema: { type: "object", properties: {} },
  487. cache: hint,
  488. },
  489. ],
  490. system: [{ type: "text", text: "system-tail", cache: hint }],
  491. messages: [Message.user([{ type: "text", text: "message-tail", cache: hint }])],
  492. }),
  493. )
  494. const body = prepared.body as {
  495. tools: Array<{ cache_control?: unknown }>
  496. system: Array<{ cache_control?: unknown }>
  497. messages: Array<{ content: Array<{ cache_control?: unknown }> }>
  498. }
  499. expect(body.tools.every((t) => t.cache_control !== undefined)).toBe(true)
  500. expect(body.system[0]?.cache_control).toBeUndefined()
  501. expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
  502. }),
  503. )
  504. })