provider-cloudflare-workers-ai.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Layer } from "effect"
  3. import { Auth } from "@opencode-ai/core/auth"
  4. import { Catalog } from "@opencode-ai/core/catalog"
  5. import { Location } from "@opencode-ai/core/location"
  6. import { EventV2 } from "@opencode-ai/core/event"
  7. import { ModelV2 } from "@opencode-ai/core/model"
  8. import { PluginV2 } from "@opencode-ai/core/plugin"
  9. import { AccountPlugin } from "@opencode-ai/core/plugin/account"
  10. import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
  11. import { ProviderV2 } from "@opencode-ai/core/provider"
  12. import { AbsolutePath } from "@opencode-ai/core/schema"
  13. import { location } from "../fixture/location"
  14. import { testEffect } from "../lib/effect"
  15. import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper"
  16. const itWithAccount = testEffect(
  17. Catalog.locationLayer.pipe(
  18. Layer.provideMerge(Auth.defaultLayer),
  19. Layer.provideMerge(EventV2.defaultLayer),
  20. Layer.provideMerge(
  21. Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
  22. ),
  23. Layer.provideMerge(npmLayer),
  24. ),
  25. )
  26. function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") {
  27. return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel(
  28. modelID,
  29. )
  30. }
  31. type CloudflareConfig = {
  32. url: (input: { path: string; modelId: string }) => string
  33. headers: () => Record<string, string> | Promise<Record<string, string>>
  34. }
  35. function cloudflareURL(sdk: unknown, modelID = "@cf/model") {
  36. return cloudflareLanguage(sdk, modelID).config.url({ path: "/chat/completions", modelId: modelID })
  37. }
  38. function cloudflareHeaders(sdk: unknown, modelID = "@cf/model") {
  39. return cloudflareLanguage(sdk, modelID).config.headers()
  40. }
  41. describe("CloudflareWorkersAIPlugin", () => {
  42. it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () =>
  43. withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
  44. Effect.gen(function* () {
  45. const plugin = yield* PluginV2.Service
  46. const catalog = yield* Catalog.Service
  47. yield* plugin.add(CloudflareWorkersAIPlugin)
  48. const transform = yield* catalog.transform()
  49. yield* transform((catalog) =>
  50. catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
  51. provider.api = { type: "aisdk", package: "test-provider" }
  52. }),
  53. )
  54. const provider = yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))
  55. const sdk = yield* plugin.trigger(
  56. "aisdk.sdk",
  57. {
  58. model: model("cloudflare-workers-ai", "@cf/model", { api: provider.api }),
  59. package: "@ai-sdk/openai-compatible",
  60. options: { name: "cloudflare-workers-ai", headers: { custom: "header" } },
  61. },
  62. {},
  63. )
  64. expect(provider.api).toEqual({
  65. type: "aisdk",
  66. package: "test-provider",
  67. url: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1",
  68. })
  69. expect(sdk.sdk).toBeDefined()
  70. }),
  71. ),
  72. )
  73. it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () =>
  74. withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
  75. Effect.gen(function* () {
  76. const plugin = yield* PluginV2.Service
  77. const catalog = yield* Catalog.Service
  78. yield* plugin.add(CloudflareWorkersAIPlugin)
  79. const transform = yield* catalog.transform()
  80. yield* transform((catalog) =>
  81. catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
  82. provider.api = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" }
  83. }),
  84. )
  85. expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
  86. type: "aisdk",
  87. package: "test-provider",
  88. url: "https://proxy.example/v1",
  89. })
  90. }),
  91. ),
  92. )
  93. it.effect("allows a configured baseURL without account ID", () =>
  94. withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () =>
  95. Effect.gen(function* () {
  96. const plugin = yield* PluginV2.Service
  97. yield* plugin.add(CloudflareWorkersAIPlugin)
  98. const result = yield* plugin.trigger(
  99. "aisdk.sdk",
  100. {
  101. model: model("cloudflare-workers-ai", "@cf/model", {
  102. api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" },
  103. }),
  104. package: "@ai-sdk/openai-compatible",
  105. options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" },
  106. },
  107. {},
  108. )
  109. expect(cloudflareURL(result.sdk)).toBe("https://proxy.example/v1/chat/completions")
  110. }),
  111. ),
  112. )
  113. itWithAccount.effect("falls back to account metadata when account env is absent", () =>
  114. withEnv(
  115. {
  116. CLOUDFLARE_ACCOUNT_ID: undefined,
  117. CLOUDFLARE_API_KEY: undefined,
  118. },
  119. () =>
  120. Effect.gen(function* () {
  121. const plugin = yield* PluginV2.Service
  122. const accounts = yield* Auth.Service
  123. const catalog = yield* Catalog.Service
  124. const events = yield* EventV2.Service
  125. yield* accounts.create({
  126. serviceID: Auth.ServiceID.make("cloudflare-workers-ai"),
  127. credential: new Auth.ApiKeyCredential({
  128. type: "api",
  129. key: "account-key",
  130. metadata: { accountId: "account-acct" },
  131. }),
  132. })
  133. yield* plugin.add({
  134. ...AccountPlugin,
  135. effect: AccountPlugin.effect.pipe(
  136. Effect.provideService(Auth.Service, accounts),
  137. Effect.provideService(Catalog.Service, catalog),
  138. Effect.provideService(EventV2.Service, events),
  139. Effect.provideService(PluginV2.Service, plugin),
  140. ),
  141. })
  142. yield* plugin.add(CloudflareWorkersAIPlugin)
  143. const transform = yield* catalog.transform()
  144. yield* transform((catalog) =>
  145. catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
  146. provider.api = { type: "aisdk", package: "test-provider" }
  147. }),
  148. )
  149. expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
  150. type: "aisdk",
  151. package: "test-provider",
  152. url: "https://api.cloudflare.com/client/v4/accounts/account-acct/ai/v1",
  153. })
  154. }),
  155. ),
  156. )
  157. it.effect("uses env account ID over configured account ID", () =>
  158. withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
  159. Effect.gen(function* () {
  160. const plugin = yield* PluginV2.Service
  161. const catalog = yield* Catalog.Service
  162. yield* plugin.add(CloudflareWorkersAIPlugin)
  163. const transform = yield* catalog.transform()
  164. yield* transform((catalog) =>
  165. catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
  166. provider.api = { type: "aisdk", package: "test-provider" }
  167. provider.request.body.accountId = "configured-acct"
  168. }),
  169. )
  170. expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
  171. type: "aisdk",
  172. package: "test-provider",
  173. url: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1",
  174. })
  175. }),
  176. ),
  177. )
  178. it.effect("uses env API key over auth or configured API key and keeps the Cloudflare User-Agent", () =>
  179. withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () =>
  180. Effect.gen(function* () {
  181. const plugin = yield* PluginV2.Service
  182. yield* plugin.add(CloudflareWorkersAIPlugin)
  183. const result = yield* plugin.trigger(
  184. "aisdk.sdk",
  185. {
  186. model: model("cloudflare-workers-ai", "@cf/model", {
  187. api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" },
  188. }),
  189. package: "@ai-sdk/openai-compatible",
  190. options: {
  191. name: "cloudflare-workers-ai",
  192. apiKey: "auth-key",
  193. baseURL: "https://proxy.example/v1",
  194. headers: { custom: "header" },
  195. },
  196. },
  197. {},
  198. )
  199. const headers = yield* Effect.promise(() => Promise.resolve(cloudflareHeaders(result.sdk)))
  200. expect(headers.authorization).toBe("Bearer env-key")
  201. expect(headers.custom).toBe("header")
  202. expect(headers["user-agent"]).toMatch(/^opencode\/.* cloudflare-workers-ai \(.+\) ai-sdk\/openai-compatible\//)
  203. }),
  204. ),
  205. )
  206. it.effect("expands account ID vars in endpoint URLs", () =>
  207. withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
  208. Effect.gen(function* () {
  209. const plugin = yield* PluginV2.Service
  210. yield* plugin.add(CloudflareWorkersAIPlugin)
  211. const result = yield* plugin.trigger(
  212. "aisdk.sdk",
  213. {
  214. model: model("cloudflare-workers-ai", "@cf/model", {
  215. api: {
  216. type: "aisdk",
  217. package: "@ai-sdk/openai-compatible",
  218. url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
  219. },
  220. }),
  221. package: "@ai-sdk/openai-compatible",
  222. options: {
  223. name: "cloudflare-workers-ai",
  224. baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
  225. },
  226. },
  227. {},
  228. )
  229. expect(cloudflareURL(result.sdk)).toBe(
  230. "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1/chat/completions",
  231. )
  232. }),
  233. ),
  234. )
  235. it.effect("selects languageModel with the API model ID", () =>
  236. Effect.gen(function* () {
  237. const plugin = yield* PluginV2.Service
  238. const calls: string[] = []
  239. yield* plugin.add(CloudflareWorkersAIPlugin)
  240. const result = yield* plugin.trigger(
  241. "aisdk.language",
  242. {
  243. model: model("cloudflare-workers-ai", "alias", { api: { id: ModelV2.ID.make("@cf/api-model") } }),
  244. sdk: fakeSelectorSdk(calls),
  245. options: {},
  246. },
  247. {},
  248. )
  249. expect(result.language).toBeDefined()
  250. expect(calls).toEqual(["languageModel:@cf/api-model"])
  251. }),
  252. )
  253. it.effect("does not create an SDK for non OpenAI-compatible packages", () =>
  254. withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
  255. Effect.gen(function* () {
  256. const plugin = yield* PluginV2.Service
  257. yield* plugin.add(CloudflareWorkersAIPlugin)
  258. const result = yield* plugin.trigger(
  259. "aisdk.sdk",
  260. {
  261. model: model("cloudflare-workers-ai", "@cf/model", {
  262. api: { type: "aisdk", package: "@ai-sdk/anthropic", url: "https://proxy.example/v1" },
  263. }),
  264. package: "@ai-sdk/anthropic",
  265. options: { name: "cloudflare-workers-ai" },
  266. },
  267. {},
  268. )
  269. expect(result.sdk).toBeUndefined()
  270. }),
  271. ),
  272. )
  273. })