anthropic-messages.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. import { describe, expect } from "bun:test"
  2. import { Effect } from "effect"
  3. import { CacheHint, LLM, LLMError } from "../../src"
  4. import { 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.model({
  10. id: "claude-sonnet-4-5",
  11. baseURL: "https://api.anthropic.test/v1/",
  12. headers: { "x-api-key": "test" },
  13. })
  14. const request = LLM.request({
  15. id: "req_1",
  16. model,
  17. system: { type: "text", text: "You are concise.", cache: new CacheHint({ type: "ephemeral" }) },
  18. prompt: "Say hello.",
  19. generation: { maxTokens: 20, temperature: 0 },
  20. })
  21. describe("Anthropic Messages route", () => {
  22. it.effect("prepares Anthropic Messages target", () =>
  23. Effect.gen(function* () {
  24. const prepared = yield* LLMClient.prepare(request)
  25. expect(prepared.body).toEqual({
  26. model: "claude-sonnet-4-5",
  27. system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
  28. messages: [{ role: "user", content: [{ type: "text", text: "Say hello." }] }],
  29. stream: true,
  30. max_tokens: 20,
  31. temperature: 0,
  32. })
  33. }),
  34. )
  35. it.effect("prepares tool call and tool result messages", () =>
  36. Effect.gen(function* () {
  37. const prepared = yield* LLMClient.prepare(
  38. LLM.request({
  39. id: "req_tool_result",
  40. model,
  41. messages: [
  42. LLM.user("What is the weather?"),
  43. LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
  44. LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
  45. ],
  46. }),
  47. )
  48. expect(prepared.body).toEqual({
  49. model: "claude-sonnet-4-5",
  50. messages: [
  51. { role: "user", content: [{ type: "text", text: "What is the weather?" }] },
  52. {
  53. role: "assistant",
  54. content: [{ type: "tool_use", id: "call_1", name: "lookup", input: { query: "weather" } }],
  55. },
  56. { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"forecast":"sunny"}' }] },
  57. ],
  58. stream: true,
  59. max_tokens: 4096,
  60. })
  61. }),
  62. )
  63. it.effect("lowers preserved Anthropic reasoning signature metadata", () =>
  64. Effect.gen(function* () {
  65. const prepared = yield* LLMClient.prepare(
  66. LLM.request({
  67. model,
  68. messages: [
  69. LLM.assistant([
  70. { type: "reasoning", text: "thinking", providerMetadata: { anthropic: { signature: "sig_1" } } },
  71. ]),
  72. ],
  73. }),
  74. )
  75. expect(prepared.body).toMatchObject({
  76. messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "thinking", signature: "sig_1" }] }],
  77. })
  78. }),
  79. )
  80. it.effect("parses text, reasoning, and usage stream fixtures", () =>
  81. Effect.gen(function* () {
  82. const body = sseEvents(
  83. { type: "message_start", message: { usage: { input_tokens: 5, cache_read_input_tokens: 1 } } },
  84. { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
  85. { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } },
  86. { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "!" } },
  87. { type: "content_block_stop", index: 0 },
  88. { type: "content_block_start", index: 1, content_block: { type: "thinking", thinking: "" } },
  89. { type: "content_block_delta", index: 1, delta: { type: "thinking_delta", thinking: "thinking" } },
  90. { type: "content_block_delta", index: 1, delta: { type: "signature_delta", signature: "sig_1" } },
  91. { type: "content_block_stop", index: 1 },
  92. {
  93. type: "message_delta",
  94. delta: { stop_reason: "end_turn", stop_sequence: "\n\nHuman:" },
  95. usage: { output_tokens: 2 },
  96. },
  97. { type: "message_stop" },
  98. )
  99. const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
  100. expect(response.text).toBe("Hello!")
  101. expect(response.reasoning).toBe("thinking")
  102. expect(response.usage).toMatchObject({
  103. inputTokens: 5,
  104. outputTokens: 2,
  105. cacheReadInputTokens: 1,
  106. totalTokens: 7,
  107. })
  108. expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toMatchObject({
  109. providerMetadata: { anthropic: { signature: "sig_1" } },
  110. })
  111. expect(response.events.at(-1)).toMatchObject({
  112. type: "request-finish",
  113. reason: "stop",
  114. providerMetadata: { anthropic: { stopSequence: "\n\nHuman:" } },
  115. })
  116. }),
  117. )
  118. it.effect("assembles streamed tool call input", () =>
  119. Effect.gen(function* () {
  120. const body = sseEvents(
  121. { type: "message_start", message: { usage: { input_tokens: 5 } } },
  122. { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "call_1", name: "lookup" } },
  123. { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '{"query"' } },
  124. { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: ':"weather"}' } },
  125. { type: "content_block_stop", index: 0 },
  126. { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
  127. )
  128. const response = yield* LLMClient.generate(
  129. LLM.updateRequest(request, {
  130. tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
  131. }),
  132. ).pipe(Effect.provide(fixedResponse(body)))
  133. expect(response.toolCalls).toEqual([
  134. { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
  135. ])
  136. expect(response.events).toEqual([
  137. { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
  138. { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
  139. { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
  140. {
  141. type: "request-finish",
  142. reason: "tool-calls",
  143. usage: { inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { input_tokens: 5, output_tokens: 1 } },
  144. },
  145. ])
  146. }),
  147. )
  148. it.effect("emits provider-error events for mid-stream provider errors", () =>
  149. Effect.gen(function* () {
  150. const response = yield* LLMClient.generate(request).pipe(
  151. Effect.provide(
  152. fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "Overloaded" } })),
  153. ),
  154. )
  155. expect(response.events).toEqual([{ type: "provider-error", message: "Overloaded" }])
  156. }),
  157. )
  158. it.effect("fails HTTP provider errors before stream parsing", () =>
  159. Effect.gen(function* () {
  160. const error = yield* LLMClient.generate(request).pipe(
  161. Effect.provide(
  162. fixedResponse('{"type":"error","error":{"type":"invalid_request_error","message":"Bad request"}}', {
  163. status: 400,
  164. headers: { "content-type": "application/json" },
  165. }),
  166. ),
  167. Effect.flip,
  168. )
  169. expect(error).toBeInstanceOf(LLMError)
  170. expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
  171. expect(error.message).toContain("HTTP 400")
  172. }),
  173. )
  174. it.effect("decodes server_tool_use + web_search_tool_result as provider-executed events", () =>
  175. Effect.gen(function* () {
  176. const body = sseEvents(
  177. { type: "message_start", message: { usage: { input_tokens: 5 } } },
  178. {
  179. type: "content_block_start",
  180. index: 0,
  181. content_block: { type: "server_tool_use", id: "srvtoolu_abc", name: "web_search" },
  182. },
  183. {
  184. type: "content_block_delta",
  185. index: 0,
  186. delta: { type: "input_json_delta", partial_json: '{"query":"effect 4"}' },
  187. },
  188. { type: "content_block_stop", index: 0 },
  189. {
  190. type: "content_block_start",
  191. index: 1,
  192. content_block: {
  193. type: "web_search_tool_result",
  194. tool_use_id: "srvtoolu_abc",
  195. content: [{ type: "web_search_result", url: "https://example.com", title: "Example" }],
  196. },
  197. },
  198. { type: "content_block_stop", index: 1 },
  199. { type: "content_block_start", index: 2, content_block: { type: "text", text: "" } },
  200. { type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Found it." } },
  201. { type: "content_block_stop", index: 2 },
  202. { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
  203. )
  204. const response = yield* LLMClient.generate(
  205. LLM.updateRequest(request, {
  206. tools: [{ name: "web_search", description: "Web search", inputSchema: { type: "object" } }],
  207. }),
  208. ).pipe(Effect.provide(fixedResponse(body)))
  209. const toolCall = response.events.find((event) => event.type === "tool-call")
  210. expect(toolCall).toEqual({
  211. type: "tool-call",
  212. id: "srvtoolu_abc",
  213. name: "web_search",
  214. input: { query: "effect 4" },
  215. providerExecuted: true,
  216. })
  217. const toolResult = response.events.find((event) => event.type === "tool-result")
  218. expect(toolResult).toEqual({
  219. type: "tool-result",
  220. id: "srvtoolu_abc",
  221. name: "web_search",
  222. result: { type: "json", value: [{ type: "web_search_result", url: "https://example.com", title: "Example" }] },
  223. providerExecuted: true,
  224. providerMetadata: { anthropic: { blockType: "web_search_tool_result" } },
  225. })
  226. expect(response.text).toBe("Found it.")
  227. expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "stop" })
  228. }),
  229. )
  230. it.effect("decodes web_search_tool_result_error as provider-executed error result", () =>
  231. Effect.gen(function* () {
  232. const body = sseEvents(
  233. { type: "message_start", message: { usage: { input_tokens: 5 } } },
  234. {
  235. type: "content_block_start",
  236. index: 0,
  237. content_block: { type: "server_tool_use", id: "srvtoolu_x", name: "web_search" },
  238. },
  239. { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '{"query":"q"}' } },
  240. { type: "content_block_stop", index: 0 },
  241. {
  242. type: "content_block_start",
  243. index: 1,
  244. content_block: {
  245. type: "web_search_tool_result",
  246. tool_use_id: "srvtoolu_x",
  247. content: { type: "web_search_tool_result_error", error_code: "max_uses_exceeded" },
  248. },
  249. },
  250. { type: "content_block_stop", index: 1 },
  251. { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
  252. )
  253. const response = yield* LLMClient.generate(
  254. LLM.updateRequest(request, {
  255. tools: [{ name: "web_search", description: "Web search", inputSchema: { type: "object" } }],
  256. }),
  257. ).pipe(Effect.provide(fixedResponse(body)))
  258. const toolResult = response.events.find((event) => event.type === "tool-result")
  259. expect(toolResult).toMatchObject({
  260. type: "tool-result",
  261. id: "srvtoolu_x",
  262. name: "web_search",
  263. result: { type: "error" },
  264. providerExecuted: true,
  265. })
  266. }),
  267. )
  268. it.effect("round-trips provider-executed assistant content into server tool blocks", () =>
  269. Effect.gen(function* () {
  270. const prepared = yield* LLMClient.prepare(
  271. LLM.request({
  272. id: "req_round_trip",
  273. model,
  274. messages: [
  275. LLM.user("Search for something."),
  276. LLM.assistant([
  277. {
  278. type: "tool-call",
  279. id: "srvtoolu_abc",
  280. name: "web_search",
  281. input: { query: "effect 4" },
  282. providerExecuted: true,
  283. },
  284. {
  285. type: "tool-result",
  286. id: "srvtoolu_abc",
  287. name: "web_search",
  288. result: { type: "json", value: [{ url: "https://example.com" }] },
  289. providerExecuted: true,
  290. },
  291. { type: "text", text: "Found it." },
  292. ]),
  293. LLM.user("Thanks."),
  294. ],
  295. }),
  296. )
  297. expect(prepared.body).toMatchObject({
  298. messages: [
  299. { role: "user", content: [{ type: "text", text: "Search for something." }] },
  300. {
  301. role: "assistant",
  302. content: [
  303. { type: "server_tool_use", id: "srvtoolu_abc", name: "web_search", input: { query: "effect 4" } },
  304. {
  305. type: "web_search_tool_result",
  306. tool_use_id: "srvtoolu_abc",
  307. content: [{ url: "https://example.com" }],
  308. },
  309. { type: "text", text: "Found it." },
  310. ],
  311. },
  312. { role: "user", content: [{ type: "text", text: "Thanks." }] },
  313. ],
  314. })
  315. }),
  316. )
  317. it.effect("rejects round-trip for unknown server tool names", () =>
  318. Effect.gen(function* () {
  319. const error = yield* LLMClient.prepare(
  320. LLM.request({
  321. id: "req_unknown_server_tool",
  322. model,
  323. messages: [
  324. LLM.assistant([
  325. {
  326. type: "tool-result",
  327. id: "srvtoolu_abc",
  328. name: "future_server_tool",
  329. result: { type: "json", value: {} },
  330. providerExecuted: true,
  331. },
  332. ]),
  333. ],
  334. }),
  335. ).pipe(Effect.flip)
  336. expect(error.message).toContain("future_server_tool")
  337. }),
  338. )
  339. it.effect("rejects unsupported user media content", () =>
  340. Effect.gen(function* () {
  341. const error = yield* LLMClient.prepare(
  342. LLM.request({
  343. id: "req_media",
  344. model,
  345. messages: [LLM.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })],
  346. }),
  347. ).pipe(Effect.flip)
  348. expect(error.message).toContain("Anthropic Messages user messages only support text content for now")
  349. }),
  350. )
  351. })