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

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