cloudflare.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. import { describe, expect } from "bun:test"
  2. import { ConfigProvider, Effect, Schema } from "effect"
  3. import { HttpClientRequest } from "effect/unstable/http"
  4. import { LLM, LLMEvent } from "../../src"
  5. import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
  6. import { compileRequest } from "../../src/route/client"
  7. import { it } from "../lib/effect"
  8. import { dynamicResponse } from "../lib/http"
  9. import { sseEvents } from "../lib/sse"
  10. const Json = Schema.fromJsonString(Schema.Unknown)
  11. const decodeJson = Schema.decodeUnknownSync(Json)
  12. const withEnv = (env: Record<string, string>) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))
  13. const deltaChunk = (delta: object, finishReason: string | null = null) => ({
  14. id: "chatcmpl_fixture",
  15. choices: [{ delta, finish_reason: finishReason }],
  16. usage: null,
  17. })
  18. describe("Cloudflare", () => {
  19. it.effect("prepares AI Gateway models through the OpenAI-compatible Chat protocol", () =>
  20. Effect.gen(function* () {
  21. const model = CloudflareAIGateway.configure({
  22. accountId: "test-account",
  23. gatewayId: "test-gateway",
  24. apiKey: "test-token",
  25. }).model("workers-ai/@cf/meta/llama-3.3-70b-instruct")
  26. expect(model).toMatchObject({
  27. id: "workers-ai/@cf/meta/llama-3.3-70b-instruct",
  28. provider: "cloudflare-ai-gateway",
  29. route: { id: "cloudflare-ai-gateway" },
  30. })
  31. expect(model.route.endpoint.baseURL).toBe("https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat")
  32. const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." }))
  33. expect(prepared.route).toBe("cloudflare-ai-gateway")
  34. expect(prepared.body).toMatchObject({
  35. model: "workers-ai/@cf/meta/llama-3.3-70b-instruct",
  36. messages: [{ role: "user", content: "Say hello." }],
  37. stream: true,
  38. })
  39. }),
  40. )
  41. it.effect("posts to the derived gateway endpoint with bearer auth", () =>
  42. Effect.gen(function* () {
  43. const response = yield* LLM.generate(
  44. LLM.request({
  45. model: CloudflareAIGateway.configure({
  46. accountId: "test-account",
  47. gatewayId: "test-gateway",
  48. apiKey: "test-token",
  49. }).model("openai/gpt-4o-mini"),
  50. prompt: "Say hello.",
  51. }),
  52. ).pipe(
  53. Effect.provide(
  54. dynamicResponse((input) =>
  55. Effect.gen(function* () {
  56. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  57. expect(web.url).toBe(
  58. "https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat/chat/completions",
  59. )
  60. expect(web.headers.get("authorization")).toBe("Bearer test-token")
  61. expect(decodeJson(input.text)).toMatchObject({
  62. model: "openai/gpt-4o-mini",
  63. stream: true,
  64. messages: [{ role: "user", content: "Say hello." }],
  65. })
  66. return input.respond(
  67. sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")),
  68. { headers: { "content-type": "text/event-stream" } },
  69. )
  70. }),
  71. ),
  72. ),
  73. )
  74. expect(response.text).toBe("Hello")
  75. }),
  76. )
  77. it.effect("preserves reasoning details for AI Gateway continuation", () =>
  78. Effect.gen(function* () {
  79. const model = CloudflareAIGateway.configure({
  80. accountId: "test-account",
  81. gatewayId: "test-gateway",
  82. apiKey: "test-token",
  83. }).model("anthropic/claude-sonnet-4.6")
  84. const details = [
  85. { type: "reasoning.text", text: "Think", format: "anthropic-claude-v1", index: 0 },
  86. { type: "reasoning.text", text: "ing", format: "anthropic-claude-v1", index: 0 },
  87. { type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
  88. ]
  89. const merged = [
  90. {
  91. type: "reasoning.text",
  92. text: "Thinking",
  93. signature: "signed",
  94. format: "anthropic-claude-v1",
  95. index: 0,
  96. },
  97. ]
  98. const response = yield* LLM.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
  99. Effect.provide(
  100. dynamicResponse((input) =>
  101. Effect.succeed(
  102. input.respond(
  103. sseEvents(
  104. deltaChunk({ reasoning: "Think", reasoning_details: [details[0]] }),
  105. deltaChunk({ reasoning: "ing", reasoning_details: [details[1]] }),
  106. deltaChunk({ reasoning_details: [details[2]] }),
  107. deltaChunk({ content: "Hello" }),
  108. deltaChunk({}, "stop"),
  109. ),
  110. { headers: { "content-type": "text/event-stream" } },
  111. ),
  112. ),
  113. ),
  114. ),
  115. )
  116. expect(response.reasoning).toBe("Thinking")
  117. expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(2)
  118. expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
  119. openai: { reasoningField: "reasoning", reasoningDetails: merged },
  120. })
  121. const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
  122. expect(replay.body.messages).toEqual([
  123. { role: "assistant", content: "Hello", reasoning: "Thinking", reasoning_details: merged },
  124. ])
  125. }),
  126. )
  127. it.effect("defaults AI Gateway id to default when omitted or blank", () =>
  128. Effect.gen(function* () {
  129. expect(
  130. CloudflareAIGateway.configure({
  131. accountId: "test-account",
  132. gatewayId: "",
  133. gatewayApiKey: "test-token",
  134. }).model("workers-ai/@cf/meta/llama-3.3-70b-instruct").route.endpoint.baseURL,
  135. ).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat")
  136. }),
  137. )
  138. it.effect("supports authenticated AI Gateway plus upstream provider auth", () =>
  139. Effect.gen(function* () {
  140. yield* LLM.generate(
  141. LLM.request({
  142. model: CloudflareAIGateway.configure({
  143. accountId: "test-account",
  144. gatewayApiKey: "gateway-token",
  145. apiKey: "provider-token",
  146. }).model("openai/gpt-4o-mini"),
  147. prompt: "Say hello.",
  148. }),
  149. ).pipe(
  150. Effect.provide(
  151. dynamicResponse((input) =>
  152. Effect.gen(function* () {
  153. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  154. expect(web.url).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat/chat/completions")
  155. expect(web.headers.get("cf-aig-authorization")).toBe("Bearer gateway-token")
  156. expect(web.headers.get("authorization")).toBe("Bearer provider-token")
  157. return input.respond(
  158. sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")),
  159. { headers: { "content-type": "text/event-stream" } },
  160. )
  161. }),
  162. ),
  163. ),
  164. )
  165. }),
  166. )
  167. it.effect("allows a fully configured baseURL override", () =>
  168. Effect.gen(function* () {
  169. const prepared = yield* compileRequest(
  170. LLM.request({
  171. model: CloudflareAIGateway.configure({
  172. baseURL: "https://gateway.proxy.test/v1/custom/compat",
  173. apiKey: "test-token",
  174. }).model("openai/gpt-4o-mini"),
  175. prompt: "Say hello.",
  176. }),
  177. )
  178. expect(prepared.model.route.endpoint.baseURL).toBe("https://gateway.proxy.test/v1/custom/compat")
  179. }),
  180. )
  181. it.effect("prepares direct Workers AI models through the OpenAI-compatible Chat protocol", () =>
  182. Effect.gen(function* () {
  183. const model = CloudflareWorkersAI.configure({
  184. accountId: "test-account",
  185. apiKey: "test-token",
  186. }).model("@cf/meta/llama-3.1-8b-instruct")
  187. expect(model).toMatchObject({
  188. id: "@cf/meta/llama-3.1-8b-instruct",
  189. provider: "cloudflare-workers-ai",
  190. route: { id: "cloudflare-workers-ai" },
  191. })
  192. expect(model.route.endpoint.baseURL).toBe("https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1")
  193. const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." }))
  194. expect(prepared.route).toBe("cloudflare-workers-ai")
  195. expect(prepared.body).toMatchObject({
  196. model: "@cf/meta/llama-3.1-8b-instruct",
  197. messages: [{ role: "user", content: "Say hello." }],
  198. stream: true,
  199. })
  200. }),
  201. )
  202. it.effect("posts direct Workers AI requests to the account endpoint with bearer auth", () =>
  203. Effect.gen(function* () {
  204. const response = yield* LLM.generate(
  205. LLM.request({
  206. model: CloudflareWorkersAI.configure({
  207. accountId: "test-account",
  208. apiKey: "test-token",
  209. }).model("@cf/meta/llama-3.1-8b-instruct"),
  210. prompt: "Say hello.",
  211. }),
  212. ).pipe(
  213. Effect.provide(
  214. dynamicResponse((input) =>
  215. Effect.gen(function* () {
  216. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  217. expect(web.url).toBe("https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1/chat/completions")
  218. expect(web.headers.get("authorization")).toBe("Bearer test-token")
  219. expect(decodeJson(input.text)).toMatchObject({
  220. model: "@cf/meta/llama-3.1-8b-instruct",
  221. stream: true,
  222. messages: [{ role: "user", content: "Say hello." }],
  223. })
  224. return input.respond(
  225. sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")),
  226. { headers: { "content-type": "text/event-stream" } },
  227. )
  228. }),
  229. ),
  230. ),
  231. )
  232. expect(response.text).toBe("Hello")
  233. }),
  234. )
  235. it.effect("supports direct Workers AI token aliases through auth config", () =>
  236. Effect.gen(function* () {
  237. yield* LLM.generate(
  238. LLM.request({
  239. model: CloudflareWorkersAI.configure({
  240. accountId: "test-account",
  241. }).model("@cf/meta/llama-3.1-8b-instruct"),
  242. prompt: "Say hello.",
  243. }),
  244. ).pipe(
  245. withEnv({ CLOUDFLARE_WORKERS_AI_TOKEN: "test-token" }),
  246. Effect.provide(
  247. dynamicResponse((input) =>
  248. Effect.gen(function* () {
  249. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  250. expect(web.headers.get("authorization")).toBe("Bearer test-token")
  251. return input.respond(
  252. sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")),
  253. { headers: { "content-type": "text/event-stream" } },
  254. )
  255. }),
  256. ),
  257. ),
  258. )
  259. }),
  260. )
  261. })