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

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