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

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