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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. import { AISDK } from "@opencode-ai/core/aisdk"
  2. import { describe, expect } from "bun:test"
  3. import { Effect } from "effect"
  4. import { Catalog } from "@opencode-ai/core/catalog"
  5. import { Model } from "@opencode-ai/core/model"
  6. import { Plugin } from "@opencode-ai/core/plugin"
  7. import { PluginHost } from "@opencode-ai/core/plugin/host"
  8. import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
  9. import { Provider } from "@opencode-ai/core/provider"
  10. import { Integration } from "@opencode-ai/core/integration"
  11. import type { LanguageModelV3 } from "@ai-sdk/provider"
  12. import { testEffect } from "../lib/effect"
  13. import { PluginTestLayer } from "./fixture"
  14. const it = testEffect(PluginTestLayer)
  15. const addPlugin = Effect.fn(function* () {
  16. const plugin = yield* Plugin.Service
  17. const aisdk = yield* AISDK.Service
  18. const host = yield* PluginHost.make(plugin)
  19. yield* CloudflareWorkersAIPlugin.effect(host)
  20. })
  21. function required<T>(value: T | undefined): T {
  22. if (value === undefined) throw new Error("Expected value")
  23. return value
  24. }
  25. function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
  26. return Effect.acquireUseRelease(
  27. Effect.sync(() => {
  28. const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
  29. Object.entries(vars).forEach(([key, value]) => {
  30. if (value === undefined) delete process.env[key]
  31. else process.env[key] = value
  32. })
  33. return previous
  34. }),
  35. effect,
  36. (previous) =>
  37. Effect.sync(() =>
  38. Object.entries(previous).forEach(([key, value]) => {
  39. if (value === undefined) delete process.env[key]
  40. else process.env[key] = value
  41. }),
  42. ),
  43. )
  44. }
  45. function fakeSelectorSdk(calls: string[]) {
  46. const make = (method: string) => (id: string) => {
  47. calls.push(`${method}:${id}`)
  48. return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
  49. }
  50. return {
  51. responses: make("responses"),
  52. messages: make("messages"),
  53. chat: make("chat"),
  54. languageModel: make("languageModel"),
  55. }
  56. }
  57. function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") {
  58. return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel(
  59. modelID,
  60. )
  61. }
  62. type CloudflareConfig = {
  63. url: (input: { path: string; modelId: string }) => string
  64. headers: () => Record<string, string> | Promise<Record<string, string>>
  65. }
  66. function cloudflareURL(sdk: unknown, modelID = "@cf/model") {
  67. return cloudflareLanguage(sdk, modelID).config.url({ path: "/chat/completions", modelId: modelID })
  68. }
  69. function cloudflareHeaders(sdk: unknown, modelID = "@cf/model") {
  70. return cloudflareLanguage(sdk, modelID).config.headers()
  71. }
  72. describe("CloudflareWorkersAIPlugin", () => {
  73. it.effect("registers an account form when the environment does not provide one", () =>
  74. withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined }, () =>
  75. Effect.gen(function* () {
  76. yield* addPlugin()
  77. expect(
  78. (yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-workers-ai")))?.methods,
  79. ).toContainEqual({
  80. type: "key",
  81. label: "API key",
  82. form: [
  83. {
  84. type: "string",
  85. key: "accountId",
  86. title: "Enter your Cloudflare Account ID",
  87. placeholder: "e.g. 1234567890abcdef1234567890abcdef",
  88. required: true,
  89. },
  90. ],
  91. })
  92. }),
  93. ),
  94. )
  95. it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () =>
  96. withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
  97. Effect.gen(function* () {
  98. const plugin = yield* Plugin.Service
  99. const aisdk = yield* AISDK.Service
  100. const catalog = yield* Catalog.Service
  101. yield* catalog.transform((catalog) =>
  102. catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
  103. provider.package = Provider.aisdk("test-provider")
  104. }),
  105. )
  106. yield* addPlugin()
  107. expect(
  108. (yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-workers-ai")))?.methods,
  109. ).toContainEqual({ type: "key", label: "API key" })
  110. const provider = required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))
  111. const sdk = yield* aisdk.runSDK({
  112. model: Model.Info.make({
  113. ...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
  114. modelID: Model.ID.make("@cf/model"),
  115. package: provider.package,
  116. settings: provider.settings,
  117. }),
  118. package: "@ai-sdk/openai-compatible",
  119. options: { name: "cloudflare-workers-ai", headers: { custom: "header" } },
  120. })
  121. expect(provider).toMatchObject({
  122. package: "aisdk:test-provider",
  123. settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1" },
  124. })
  125. expect(sdk.sdk).toBeDefined()
  126. }),
  127. ),
  128. )
  129. it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () =>
  130. withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
  131. Effect.gen(function* () {
  132. const catalog = yield* Catalog.Service
  133. yield* catalog.transform((catalog) =>
  134. catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
  135. provider.package = Provider.aisdk("test-provider")
  136. provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" }
  137. }),
  138. )
  139. yield* addPlugin()
  140. expect(required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))).toMatchObject({
  141. package: "aisdk:test-provider",
  142. settings: { baseURL: "https://proxy.example/v1" },
  143. })
  144. }),
  145. ),
  146. )
  147. it.effect("allows a configured baseURL without account ID", () =>
  148. withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () =>
  149. Effect.gen(function* () {
  150. const plugin = yield* Plugin.Service
  151. const aisdk = yield* AISDK.Service
  152. const catalog = yield* Catalog.Service
  153. yield* catalog.transform((catalog) =>
  154. catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
  155. provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" }
  156. }),
  157. )
  158. yield* addPlugin()
  159. expect(
  160. (yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-workers-ai")))?.methods,
  161. ).toContainEqual({ type: "key", label: "API key" })
  162. const result = yield* aisdk.runSDK({
  163. model: Model.Info.make({
  164. ...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
  165. modelID: Model.ID.make("@cf/model"),
  166. package: "aisdk:@ai-sdk/openai-compatible",
  167. settings: { baseURL: "https://proxy.example/v1" },
  168. }),
  169. package: "@ai-sdk/openai-compatible",
  170. options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" },
  171. })
  172. expect(cloudflareURL(result.sdk)).toBe("https://proxy.example/v1/chat/completions")
  173. }),
  174. ),
  175. )
  176. it.effect("uses env account ID over configured account ID", () =>
  177. withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
  178. Effect.gen(function* () {
  179. const catalog = yield* Catalog.Service
  180. yield* catalog.transform((catalog) =>
  181. catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
  182. provider.package = Provider.aisdk("test-provider")
  183. provider.settings = { ...provider.settings, accountId: "configured-acct" }
  184. }),
  185. )
  186. yield* addPlugin()
  187. expect(required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))).toMatchObject({
  188. package: "aisdk:test-provider",
  189. settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1" },
  190. })
  191. }),
  192. ),
  193. )
  194. it.effect("uses env API key over auth or configured API key and keeps the Cloudflare User-Agent", () =>
  195. withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () =>
  196. Effect.gen(function* () {
  197. const plugin = yield* Plugin.Service
  198. const aisdk = yield* AISDK.Service
  199. yield* addPlugin()
  200. const result = yield* aisdk.runSDK({
  201. model: Model.Info.make({
  202. ...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
  203. modelID: Model.ID.make("@cf/model"),
  204. package: "aisdk:@ai-sdk/openai-compatible",
  205. settings: { baseURL: "https://proxy.example/v1" },
  206. }),
  207. package: "@ai-sdk/openai-compatible",
  208. options: {
  209. name: "cloudflare-workers-ai",
  210. apiKey: "auth-key",
  211. baseURL: "https://proxy.example/v1",
  212. headers: { custom: "header" },
  213. },
  214. })
  215. const headers = yield* Effect.promise(() => Promise.resolve(cloudflareHeaders(result.sdk)))
  216. expect(headers.authorization).toBe("Bearer env-key")
  217. expect(headers.custom).toBe("header")
  218. expect(headers["user-agent"]).toMatch(/^opencode\/.* cloudflare-workers-ai \(.+\) ai-sdk\/openai-compatible\//)
  219. }),
  220. ),
  221. )
  222. it.effect("expands account ID vars in endpoint URLs", () =>
  223. withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
  224. Effect.gen(function* () {
  225. const plugin = yield* Plugin.Service
  226. const aisdk = yield* AISDK.Service
  227. yield* addPlugin()
  228. const result = yield* aisdk.runSDK({
  229. model: Model.Info.make({
  230. ...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
  231. modelID: Model.ID.make("@cf/model"),
  232. package: "aisdk:@ai-sdk/openai-compatible",
  233. settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1" },
  234. }),
  235. package: "@ai-sdk/openai-compatible",
  236. options: {
  237. name: "cloudflare-workers-ai",
  238. baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
  239. },
  240. })
  241. expect(cloudflareURL(result.sdk)).toBe(
  242. "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1/chat/completions",
  243. )
  244. }),
  245. ),
  246. )
  247. it.effect("selects languageModel with the API model ID", () =>
  248. Effect.gen(function* () {
  249. const plugin = yield* Plugin.Service
  250. const aisdk = yield* AISDK.Service
  251. const calls: string[] = []
  252. yield* addPlugin()
  253. const result = yield* aisdk.runLanguage({
  254. model: Model.Info.make({
  255. ...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("alias")),
  256. modelID: Model.ID.make("@cf/api-model"),
  257. package: "aisdk:test-provider",
  258. }),
  259. sdk: fakeSelectorSdk(calls),
  260. options: {},
  261. })
  262. expect(result.language).toBeDefined()
  263. expect(calls).toEqual(["languageModel:@cf/api-model"])
  264. }),
  265. )
  266. it.effect("does not create an SDK for non OpenAI-compatible packages", () =>
  267. withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
  268. Effect.gen(function* () {
  269. const plugin = yield* Plugin.Service
  270. const aisdk = yield* AISDK.Service
  271. yield* addPlugin()
  272. const result = yield* aisdk.runSDK({
  273. model: Model.Info.make({
  274. ...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
  275. modelID: Model.ID.make("@cf/model"),
  276. package: "aisdk:@ai-sdk/anthropic",
  277. settings: { baseURL: "https://proxy.example/v1" },
  278. }),
  279. package: "@ai-sdk/anthropic",
  280. options: { name: "cloudflare-workers-ai" },
  281. })
  282. expect(result.sdk).toBeUndefined()
  283. }),
  284. ),
  285. )
  286. })