provider-cloudflare-ai-gateway.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. import { AISDK } from "@opencode-ai/core/aisdk"
  2. import { describe, expect, mock } from "bun:test"
  3. import { Effect } from "effect"
  4. import { Model } from "@opencode-ai/core/model"
  5. import { Plugin } from "@opencode-ai/core/plugin"
  6. import { PluginHost } from "@opencode-ai/core/plugin/host"
  7. import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway"
  8. import { Provider } from "@opencode-ai/core/provider"
  9. import { Integration } from "@opencode-ai/core/integration"
  10. import { testEffect } from "../lib/effect"
  11. import { PluginTestLayer } from "./fixture"
  12. const it = testEffect(PluginTestLayer)
  13. const addPlugin = Effect.fn(function* () {
  14. const plugin = yield* Plugin.Service
  15. const aisdk = yield* AISDK.Service
  16. const host = yield* PluginHost.make(plugin)
  17. yield* CloudflareAIGatewayPlugin.effect(host)
  18. })
  19. function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
  20. return Effect.acquireUseRelease(
  21. Effect.sync(() => {
  22. const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
  23. Object.entries(vars).forEach(([key, value]) => {
  24. if (value === undefined) delete process.env[key]
  25. else process.env[key] = value
  26. })
  27. return previous
  28. }),
  29. fx,
  30. (previous) =>
  31. Effect.sync(() => {
  32. Object.entries(previous).forEach(([key, value]) => {
  33. if (value === undefined) delete process.env[key]
  34. else process.env[key] = value
  35. })
  36. }),
  37. )
  38. }
  39. const aiGatewayCalls: Record<string, unknown>[] = []
  40. const unifiedCalls: string[] = []
  41. const gatewayModelCalls: unknown[] = []
  42. function captureAiGatewayOptions(options: Record<string, unknown>) {
  43. const nested =
  44. options.options && typeof options.options === "object" ? (options.options as Record<string, unknown>) : undefined
  45. return {
  46. ...options,
  47. ...(nested
  48. ? {
  49. options: {
  50. ...nested,
  51. headers:
  52. nested.headers && typeof nested.headers === "object"
  53. ? { ...(nested.headers as Record<string, unknown>) }
  54. : nested.headers,
  55. },
  56. }
  57. : {}),
  58. }
  59. }
  60. function resetCalls() {
  61. aiGatewayCalls.length = 0
  62. unifiedCalls.length = 0
  63. gatewayModelCalls.length = 0
  64. }
  65. function cloudflareEnv(overrides: Record<string, string | undefined> = {}) {
  66. return {
  67. CLOUDFLARE_ACCOUNT_ID: "env-account",
  68. CLOUDFLARE_GATEWAY_ID: "env-gateway",
  69. CLOUDFLARE_API_TOKEN: "env-token",
  70. CF_AIG_TOKEN: undefined,
  71. ...overrides,
  72. }
  73. }
  74. mock.module("ai-gateway-provider", () => ({
  75. createAiGateway(options: Record<string, unknown>) {
  76. aiGatewayCalls.push(captureAiGatewayOptions(options))
  77. return (input: unknown) => {
  78. gatewayModelCalls.push(input)
  79. return {
  80. modelId: input,
  81. provider: "cloudflare-ai-gateway",
  82. specificationVersion: "v3",
  83. }
  84. }
  85. },
  86. }))
  87. mock.module("ai-gateway-provider/providers/unified", () => ({
  88. createUnified() {
  89. return (modelID: string) => {
  90. unifiedCalls.push(modelID)
  91. return { unifiedModelID: modelID }
  92. }
  93. },
  94. }))
  95. describe("CloudflareAIGatewayPlugin", () => {
  96. it.effect("prompts for account and gateway IDs when the environment does not provide them", () =>
  97. withEnv(
  98. cloudflareEnv({
  99. CLOUDFLARE_ACCOUNT_ID: undefined,
  100. CLOUDFLARE_GATEWAY_ID: undefined,
  101. }),
  102. () =>
  103. Effect.gen(function* () {
  104. yield* addPlugin()
  105. expect(
  106. (yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-ai-gateway")))?.methods,
  107. ).toContainEqual({
  108. type: "key",
  109. label: "Gateway API token",
  110. prompts: [
  111. {
  112. type: "text",
  113. key: "accountId",
  114. message: "Enter your Cloudflare Account ID",
  115. placeholder: "e.g. 1234567890abcdef1234567890abcdef",
  116. },
  117. {
  118. type: "text",
  119. key: "gatewayId",
  120. message: "Enter your Cloudflare AI Gateway ID",
  121. placeholder: "e.g. my-gateway",
  122. },
  123. ],
  124. })
  125. }),
  126. ),
  127. )
  128. it.effect("requires account, gateway, and token before creating the unified SDK", () =>
  129. withEnv(
  130. {
  131. CLOUDFLARE_ACCOUNT_ID: "acct",
  132. CLOUDFLARE_GATEWAY_ID: "gateway",
  133. CLOUDFLARE_API_TOKEN: "token",
  134. CF_AIG_TOKEN: undefined,
  135. },
  136. () =>
  137. Effect.gen(function* () {
  138. const plugin = yield* Plugin.Service
  139. const aisdk = yield* AISDK.Service
  140. yield* addPlugin()
  141. const result = yield* aisdk.runSDK({
  142. model: Model.Info.make({
  143. ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
  144. modelID: Model.ID.make("openai/gpt-5"),
  145. package: "aisdk:test-provider",
  146. }),
  147. package: "ai-gateway-provider",
  148. options: { name: "cloudflare-ai-gateway" },
  149. })
  150. expect(result.sdk.languageModel("openai/gpt-5")).toBeDefined()
  151. }),
  152. ),
  153. )
  154. it.effect("passes legacy metadata, cache, log, and User-Agent values under the AI Gateway options key", () =>
  155. withEnv(cloudflareEnv(), () =>
  156. Effect.gen(function* () {
  157. resetCalls()
  158. const plugin = yield* Plugin.Service
  159. const aisdk = yield* AISDK.Service
  160. yield* addPlugin()
  161. yield* aisdk.runSDK({
  162. model: Model.Info.make({
  163. ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
  164. modelID: Model.ID.make("openai/gpt-5"),
  165. package: "aisdk:test-provider",
  166. }),
  167. package: "ai-gateway-provider",
  168. options: {
  169. name: "cloudflare-ai-gateway",
  170. metadata: { invoked_by: "test", project: "opencode" },
  171. cacheTtl: 300,
  172. cacheKey: "cache-key",
  173. skipCache: true,
  174. collectLog: false,
  175. },
  176. })
  177. expect(aiGatewayCalls).toHaveLength(1)
  178. expect(aiGatewayCalls[0]).toEqual({
  179. accountId: "env-account",
  180. gateway: "env-gateway",
  181. apiKey: "env-token",
  182. options: {
  183. metadata: { invoked_by: "test", project: "opencode" },
  184. cacheTtl: 300,
  185. cacheKey: "cache-key",
  186. skipCache: true,
  187. collectLog: false,
  188. headers: {
  189. "User-Agent": expect.stringContaining("opencode/"),
  190. },
  191. },
  192. })
  193. }),
  194. ),
  195. )
  196. it.effect("parses legacy cf-aig-metadata header when metadata option is absent", () =>
  197. withEnv(cloudflareEnv(), () =>
  198. Effect.gen(function* () {
  199. resetCalls()
  200. const plugin = yield* Plugin.Service
  201. const aisdk = yield* AISDK.Service
  202. yield* addPlugin()
  203. yield* aisdk.runSDK({
  204. model: Model.Info.make({
  205. ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
  206. modelID: Model.ID.make("openai/gpt-5"),
  207. package: "aisdk:test-provider",
  208. }),
  209. package: "ai-gateway-provider",
  210. options: {
  211. name: "cloudflare-ai-gateway",
  212. headers: {
  213. "cf-aig-metadata": JSON.stringify({ invoked_by: "header", project: "opencode" }),
  214. },
  215. },
  216. })
  217. expect(aiGatewayCalls[0]?.options).toMatchObject({
  218. metadata: { invoked_by: "header", project: "opencode" },
  219. })
  220. }),
  221. ),
  222. )
  223. it.effect("prefers Cloudflare env values over auth/config-derived options", () =>
  224. withEnv(cloudflareEnv(), () =>
  225. Effect.gen(function* () {
  226. resetCalls()
  227. const plugin = yield* Plugin.Service
  228. const aisdk = yield* AISDK.Service
  229. yield* addPlugin()
  230. yield* aisdk.runSDK({
  231. model: Model.Info.make({
  232. ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
  233. modelID: Model.ID.make("openai/gpt-5"),
  234. package: "aisdk:test-provider",
  235. }),
  236. package: "ai-gateway-provider",
  237. options: {
  238. name: "cloudflare-ai-gateway",
  239. accountId: "auth-account",
  240. gateway: "auth-gateway",
  241. apiKey: "auth-token",
  242. },
  243. })
  244. expect(aiGatewayCalls[0]).toMatchObject({
  245. accountId: "env-account",
  246. gateway: "env-gateway",
  247. apiKey: "env-token",
  248. })
  249. }),
  250. ),
  251. )
  252. it.effect("accepts gatewayId metadata copied from auth into provider options", () =>
  253. withEnv(
  254. cloudflareEnv({
  255. CLOUDFLARE_ACCOUNT_ID: undefined,
  256. CLOUDFLARE_GATEWAY_ID: undefined,
  257. CLOUDFLARE_API_TOKEN: undefined,
  258. }),
  259. () =>
  260. Effect.gen(function* () {
  261. resetCalls()
  262. const plugin = yield* Plugin.Service
  263. const aisdk = yield* AISDK.Service
  264. yield* addPlugin()
  265. yield* aisdk.runSDK({
  266. model: Model.Info.make({
  267. ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
  268. modelID: Model.ID.make("openai/gpt-5"),
  269. package: "aisdk:test-provider",
  270. }),
  271. package: "ai-gateway-provider",
  272. options: {
  273. name: "cloudflare-ai-gateway",
  274. accountId: "auth-account",
  275. gatewayId: "auth-gateway",
  276. apiKey: "auth-token",
  277. },
  278. })
  279. expect(aiGatewayCalls[0]).toMatchObject({
  280. accountId: "auth-account",
  281. gateway: "auth-gateway",
  282. apiKey: "auth-token",
  283. })
  284. }),
  285. ),
  286. )
  287. it.effect("falls back to CF_AIG_TOKEN when CLOUDFLARE_API_TOKEN is unset", () =>
  288. withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: "cf-aig-token" }), () =>
  289. Effect.gen(function* () {
  290. resetCalls()
  291. const plugin = yield* Plugin.Service
  292. const aisdk = yield* AISDK.Service
  293. yield* addPlugin()
  294. yield* aisdk.runSDK({
  295. model: Model.Info.make({
  296. ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
  297. modelID: Model.ID.make("openai/gpt-5"),
  298. package: "aisdk:test-provider",
  299. }),
  300. package: "ai-gateway-provider",
  301. options: { name: "cloudflare-ai-gateway" },
  302. })
  303. expect(aiGatewayCalls[0]).toMatchObject({ apiKey: "cf-aig-token" })
  304. }),
  305. ),
  306. )
  307. it.effect("does not create an SDK when account and gateway IDs are missing", () =>
  308. withEnv(cloudflareEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }), () =>
  309. Effect.gen(function* () {
  310. resetCalls()
  311. const plugin = yield* Plugin.Service
  312. const aisdk = yield* AISDK.Service
  313. yield* addPlugin()
  314. const result = yield* aisdk.runSDK({
  315. model: Model.Info.make({
  316. ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
  317. modelID: Model.ID.make("openai/gpt-5"),
  318. package: "aisdk:test-provider",
  319. }),
  320. package: "ai-gateway-provider",
  321. options: { name: "cloudflare-ai-gateway" },
  322. })
  323. expect(result.sdk).toBeUndefined()
  324. expect(aiGatewayCalls).toHaveLength(0)
  325. }),
  326. ),
  327. )
  328. it.effect("does not create an SDK when the token is missing", () =>
  329. withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: undefined }), () =>
  330. Effect.gen(function* () {
  331. resetCalls()
  332. const plugin = yield* Plugin.Service
  333. const aisdk = yield* AISDK.Service
  334. yield* addPlugin()
  335. const result = yield* aisdk.runSDK({
  336. model: Model.Info.make({
  337. ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
  338. modelID: Model.ID.make("openai/gpt-5"),
  339. package: "aisdk:test-provider",
  340. }),
  341. package: "ai-gateway-provider",
  342. options: { name: "cloudflare-ai-gateway" },
  343. })
  344. expect(result.sdk).toBeUndefined()
  345. expect(aiGatewayCalls).toHaveLength(0)
  346. }),
  347. ),
  348. )
  349. it.effect("does not replace a configured baseURL with the Cloudflare AI Gateway SDK", () =>
  350. withEnv(
  351. cloudflareEnv({
  352. CLOUDFLARE_ACCOUNT_ID: undefined,
  353. CLOUDFLARE_GATEWAY_ID: undefined,
  354. CLOUDFLARE_API_TOKEN: undefined,
  355. }),
  356. () =>
  357. Effect.gen(function* () {
  358. resetCalls()
  359. const plugin = yield* Plugin.Service
  360. const aisdk = yield* AISDK.Service
  361. yield* addPlugin()
  362. const result = yield* aisdk.runSDK({
  363. model: Model.Info.make({
  364. ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
  365. modelID: Model.ID.make("openai/gpt-5"),
  366. package: "aisdk:test-provider",
  367. }),
  368. package: "ai-gateway-provider",
  369. options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" },
  370. })
  371. expect(result.sdk).toBeUndefined()
  372. expect(aiGatewayCalls).toHaveLength(0)
  373. }),
  374. ),
  375. )
  376. it.effect("maps provider/model IDs through the unified Cloudflare provider unchanged", () =>
  377. withEnv(cloudflareEnv(), () =>
  378. Effect.gen(function* () {
  379. resetCalls()
  380. const plugin = yield* Plugin.Service
  381. const aisdk = yield* AISDK.Service
  382. yield* addPlugin()
  383. const result = yield* aisdk.runSDK({
  384. model: Model.Info.make({
  385. ...Model.Info.default(
  386. Provider.ID.make("cloudflare-ai-gateway"),
  387. Model.ID.make("anthropic/claude-sonnet-4-5"),
  388. ),
  389. modelID: Model.ID.make("anthropic/claude-sonnet-4-5"),
  390. package: "aisdk:test-provider",
  391. }),
  392. package: "ai-gateway-provider",
  393. options: { name: "cloudflare-ai-gateway" },
  394. })
  395. expect(result.sdk.languageModel("anthropic/claude-sonnet-4-5")).toEqual({
  396. modelId: { unifiedModelID: "anthropic/claude-sonnet-4-5" },
  397. provider: "cloudflare-ai-gateway",
  398. specificationVersion: "v3",
  399. })
  400. expect(unifiedCalls).toEqual(["anthropic/claude-sonnet-4-5"])
  401. expect(gatewayModelCalls).toEqual([{ unifiedModelID: "anthropic/claude-sonnet-4-5" }])
  402. }),
  403. ),
  404. )
  405. it.effect("ignores non Cloudflare AI Gateway packages", () =>
  406. withEnv(cloudflareEnv(), () =>
  407. Effect.gen(function* () {
  408. resetCalls()
  409. const plugin = yield* Plugin.Service
  410. const aisdk = yield* AISDK.Service
  411. yield* addPlugin()
  412. const result = yield* aisdk.runSDK({
  413. model: Model.Info.make({
  414. ...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
  415. modelID: Model.ID.make("openai/gpt-5"),
  416. package: "aisdk:test-provider",
  417. }),
  418. package: "@ai-sdk/openai-compatible",
  419. options: { name: "cloudflare-ai-gateway" },
  420. })
  421. expect(result.sdk).toBeUndefined()
  422. expect(aiGatewayCalls).toHaveLength(0)
  423. }),
  424. ),
  425. )
  426. })