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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. import { describe, expect, mock } from "bun:test"
  2. import { Effect } from "effect"
  3. import { PluginV2 } from "@opencode-ai/core/plugin"
  4. import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway"
  5. import { it, model, withEnv } from "./provider-helper"
  6. const aiGatewayCalls: Record<string, unknown>[] = []
  7. const unifiedCalls: string[] = []
  8. const gatewayModelCalls: unknown[] = []
  9. function captureAiGatewayOptions(options: Record<string, unknown>) {
  10. const nested =
  11. options.options && typeof options.options === "object" ? (options.options as Record<string, unknown>) : undefined
  12. return {
  13. ...options,
  14. ...(nested
  15. ? {
  16. options: {
  17. ...nested,
  18. headers:
  19. nested.headers && typeof nested.headers === "object"
  20. ? { ...(nested.headers as Record<string, unknown>) }
  21. : nested.headers,
  22. },
  23. }
  24. : {}),
  25. }
  26. }
  27. function resetCalls() {
  28. aiGatewayCalls.length = 0
  29. unifiedCalls.length = 0
  30. gatewayModelCalls.length = 0
  31. }
  32. function cloudflareEnv(overrides: Record<string, string | undefined> = {}) {
  33. return {
  34. CLOUDFLARE_ACCOUNT_ID: "env-account",
  35. CLOUDFLARE_GATEWAY_ID: "env-gateway",
  36. CLOUDFLARE_API_TOKEN: "env-token",
  37. CF_AIG_TOKEN: undefined,
  38. ...overrides,
  39. }
  40. }
  41. mock.module("ai-gateway-provider", () => ({
  42. createAiGateway(options: Record<string, unknown>) {
  43. aiGatewayCalls.push(captureAiGatewayOptions(options))
  44. return (input: unknown) => {
  45. gatewayModelCalls.push(input)
  46. return {
  47. modelId: input,
  48. provider: "cloudflare-ai-gateway",
  49. specificationVersion: "v3",
  50. }
  51. }
  52. },
  53. }))
  54. mock.module("ai-gateway-provider/providers/unified", () => ({
  55. createUnified() {
  56. return (modelID: string) => {
  57. unifiedCalls.push(modelID)
  58. return { unifiedModelID: modelID }
  59. }
  60. },
  61. }))
  62. describe("CloudflareAIGatewayPlugin", () => {
  63. it.effect("requires account, gateway, and token before creating the unified SDK", () =>
  64. withEnv(
  65. {
  66. CLOUDFLARE_ACCOUNT_ID: "acct",
  67. CLOUDFLARE_GATEWAY_ID: "gateway",
  68. CLOUDFLARE_API_TOKEN: "token",
  69. CF_AIG_TOKEN: undefined,
  70. },
  71. () =>
  72. Effect.gen(function* () {
  73. const plugin = yield* PluginV2.Service
  74. yield* plugin.add(CloudflareAIGatewayPlugin)
  75. const result = yield* plugin.trigger(
  76. "aisdk.sdk",
  77. {
  78. model: model("cloudflare-ai-gateway", "openai/gpt-5"),
  79. package: "ai-gateway-provider",
  80. options: { name: "cloudflare-ai-gateway" },
  81. },
  82. {},
  83. )
  84. expect(result.sdk.languageModel("openai/gpt-5")).toBeDefined()
  85. }),
  86. ),
  87. )
  88. it.effect("passes legacy metadata, cache, log, and User-Agent values under the AI Gateway options key", () =>
  89. withEnv(cloudflareEnv(), () =>
  90. Effect.gen(function* () {
  91. resetCalls()
  92. const plugin = yield* PluginV2.Service
  93. yield* plugin.add(CloudflareAIGatewayPlugin)
  94. yield* plugin.trigger(
  95. "aisdk.sdk",
  96. {
  97. model: model("cloudflare-ai-gateway", "openai/gpt-5"),
  98. package: "ai-gateway-provider",
  99. options: {
  100. name: "cloudflare-ai-gateway",
  101. metadata: { invoked_by: "test", project: "opencode" },
  102. cacheTtl: 300,
  103. cacheKey: "cache-key",
  104. skipCache: true,
  105. collectLog: false,
  106. },
  107. },
  108. {},
  109. )
  110. expect(aiGatewayCalls).toHaveLength(1)
  111. expect(aiGatewayCalls[0]).toEqual({
  112. accountId: "env-account",
  113. gateway: "env-gateway",
  114. apiKey: "env-token",
  115. options: {
  116. metadata: { invoked_by: "test", project: "opencode" },
  117. cacheTtl: 300,
  118. cacheKey: "cache-key",
  119. skipCache: true,
  120. collectLog: false,
  121. headers: {
  122. "User-Agent": expect.stringContaining("opencode/"),
  123. },
  124. },
  125. })
  126. }),
  127. ),
  128. )
  129. it.effect("parses legacy cf-aig-metadata header when metadata option is absent", () =>
  130. withEnv(cloudflareEnv(), () =>
  131. Effect.gen(function* () {
  132. resetCalls()
  133. const plugin = yield* PluginV2.Service
  134. yield* plugin.add(CloudflareAIGatewayPlugin)
  135. yield* plugin.trigger(
  136. "aisdk.sdk",
  137. {
  138. model: model("cloudflare-ai-gateway", "openai/gpt-5"),
  139. package: "ai-gateway-provider",
  140. options: {
  141. name: "cloudflare-ai-gateway",
  142. headers: {
  143. "cf-aig-metadata": JSON.stringify({ invoked_by: "header", project: "opencode" }),
  144. },
  145. },
  146. },
  147. {},
  148. )
  149. expect(aiGatewayCalls[0]?.options).toMatchObject({
  150. metadata: { invoked_by: "header", project: "opencode" },
  151. })
  152. }),
  153. ),
  154. )
  155. it.effect("prefers Cloudflare env values over auth/config-derived options", () =>
  156. withEnv(cloudflareEnv(), () =>
  157. Effect.gen(function* () {
  158. resetCalls()
  159. const plugin = yield* PluginV2.Service
  160. yield* plugin.add(CloudflareAIGatewayPlugin)
  161. yield* plugin.trigger(
  162. "aisdk.sdk",
  163. {
  164. model: model("cloudflare-ai-gateway", "openai/gpt-5"),
  165. package: "ai-gateway-provider",
  166. options: {
  167. name: "cloudflare-ai-gateway",
  168. accountId: "auth-account",
  169. gateway: "auth-gateway",
  170. apiKey: "auth-token",
  171. },
  172. },
  173. {},
  174. )
  175. expect(aiGatewayCalls[0]).toMatchObject({
  176. accountId: "env-account",
  177. gateway: "env-gateway",
  178. apiKey: "env-token",
  179. })
  180. }),
  181. ),
  182. )
  183. it.effect("accepts gatewayId metadata copied from auth into provider options", () =>
  184. withEnv(
  185. cloudflareEnv({
  186. CLOUDFLARE_ACCOUNT_ID: undefined,
  187. CLOUDFLARE_GATEWAY_ID: undefined,
  188. CLOUDFLARE_API_TOKEN: undefined,
  189. }),
  190. () =>
  191. Effect.gen(function* () {
  192. resetCalls()
  193. const plugin = yield* PluginV2.Service
  194. yield* plugin.add(CloudflareAIGatewayPlugin)
  195. yield* plugin.trigger(
  196. "aisdk.sdk",
  197. {
  198. model: model("cloudflare-ai-gateway", "openai/gpt-5"),
  199. package: "ai-gateway-provider",
  200. options: {
  201. name: "cloudflare-ai-gateway",
  202. accountId: "auth-account",
  203. gatewayId: "auth-gateway",
  204. apiKey: "auth-token",
  205. },
  206. },
  207. {},
  208. )
  209. expect(aiGatewayCalls[0]).toMatchObject({
  210. accountId: "auth-account",
  211. gateway: "auth-gateway",
  212. apiKey: "auth-token",
  213. })
  214. }),
  215. ),
  216. )
  217. it.effect("falls back to CF_AIG_TOKEN when CLOUDFLARE_API_TOKEN is unset", () =>
  218. withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: "cf-aig-token" }), () =>
  219. Effect.gen(function* () {
  220. resetCalls()
  221. const plugin = yield* PluginV2.Service
  222. yield* plugin.add(CloudflareAIGatewayPlugin)
  223. yield* plugin.trigger(
  224. "aisdk.sdk",
  225. {
  226. model: model("cloudflare-ai-gateway", "openai/gpt-5"),
  227. package: "ai-gateway-provider",
  228. options: { name: "cloudflare-ai-gateway" },
  229. },
  230. {},
  231. )
  232. expect(aiGatewayCalls[0]).toMatchObject({ apiKey: "cf-aig-token" })
  233. }),
  234. ),
  235. )
  236. it.effect("does not create an SDK when account and gateway IDs are missing", () =>
  237. withEnv(cloudflareEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }), () =>
  238. Effect.gen(function* () {
  239. resetCalls()
  240. const plugin = yield* PluginV2.Service
  241. yield* plugin.add(CloudflareAIGatewayPlugin)
  242. const result = yield* plugin.trigger(
  243. "aisdk.sdk",
  244. {
  245. model: model("cloudflare-ai-gateway", "openai/gpt-5"),
  246. package: "ai-gateway-provider",
  247. options: { name: "cloudflare-ai-gateway" },
  248. },
  249. {},
  250. )
  251. expect(result.sdk).toBeUndefined()
  252. expect(aiGatewayCalls).toHaveLength(0)
  253. }),
  254. ),
  255. )
  256. it.effect("does not create an SDK when the token is missing", () =>
  257. withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: undefined }), () =>
  258. Effect.gen(function* () {
  259. resetCalls()
  260. const plugin = yield* PluginV2.Service
  261. yield* plugin.add(CloudflareAIGatewayPlugin)
  262. const result = yield* plugin.trigger(
  263. "aisdk.sdk",
  264. {
  265. model: model("cloudflare-ai-gateway", "openai/gpt-5"),
  266. package: "ai-gateway-provider",
  267. options: { name: "cloudflare-ai-gateway" },
  268. },
  269. {},
  270. )
  271. expect(result.sdk).toBeUndefined()
  272. expect(aiGatewayCalls).toHaveLength(0)
  273. }),
  274. ),
  275. )
  276. it.effect("does not replace a configured baseURL with the Cloudflare AI Gateway SDK", () =>
  277. withEnv(
  278. cloudflareEnv({
  279. CLOUDFLARE_ACCOUNT_ID: undefined,
  280. CLOUDFLARE_GATEWAY_ID: undefined,
  281. CLOUDFLARE_API_TOKEN: undefined,
  282. }),
  283. () =>
  284. Effect.gen(function* () {
  285. resetCalls()
  286. const plugin = yield* PluginV2.Service
  287. yield* plugin.add(CloudflareAIGatewayPlugin)
  288. const result = yield* plugin.trigger(
  289. "aisdk.sdk",
  290. {
  291. model: model("cloudflare-ai-gateway", "openai/gpt-5"),
  292. package: "ai-gateway-provider",
  293. options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" },
  294. },
  295. {},
  296. )
  297. expect(result.sdk).toBeUndefined()
  298. expect(aiGatewayCalls).toHaveLength(0)
  299. }),
  300. ),
  301. )
  302. it.effect("maps provider/model IDs through the unified Cloudflare provider unchanged", () =>
  303. withEnv(cloudflareEnv(), () =>
  304. Effect.gen(function* () {
  305. resetCalls()
  306. const plugin = yield* PluginV2.Service
  307. yield* plugin.add(CloudflareAIGatewayPlugin)
  308. const result = yield* plugin.trigger(
  309. "aisdk.sdk",
  310. {
  311. model: model("cloudflare-ai-gateway", "anthropic/claude-sonnet-4-5"),
  312. package: "ai-gateway-provider",
  313. options: { name: "cloudflare-ai-gateway" },
  314. },
  315. {},
  316. )
  317. expect(result.sdk.languageModel("anthropic/claude-sonnet-4-5")).toEqual({
  318. modelId: { unifiedModelID: "anthropic/claude-sonnet-4-5" },
  319. provider: "cloudflare-ai-gateway",
  320. specificationVersion: "v3",
  321. })
  322. expect(unifiedCalls).toEqual(["anthropic/claude-sonnet-4-5"])
  323. expect(gatewayModelCalls).toEqual([{ unifiedModelID: "anthropic/claude-sonnet-4-5" }])
  324. }),
  325. ),
  326. )
  327. it.effect("ignores non Cloudflare AI Gateway packages", () =>
  328. withEnv(cloudflareEnv(), () =>
  329. Effect.gen(function* () {
  330. resetCalls()
  331. const plugin = yield* PluginV2.Service
  332. yield* plugin.add(CloudflareAIGatewayPlugin)
  333. const result = yield* plugin.trigger(
  334. "aisdk.sdk",
  335. {
  336. model: model("cloudflare-ai-gateway", "openai/gpt-5"),
  337. package: "@ai-sdk/openai-compatible",
  338. options: { name: "cloudflare-ai-gateway" },
  339. },
  340. {},
  341. )
  342. expect(result.sdk).toBeUndefined()
  343. expect(aiGatewayCalls).toHaveLength(0)
  344. }),
  345. ),
  346. )
  347. })