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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  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. modelID: ModelV2.ID.make("openai/gpt-5"),
  112. package: "aisdk:test-provider",
  113. }),
  114. package: "ai-gateway-provider",
  115. options: { name: "cloudflare-ai-gateway" },
  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. const aisdk = yield* AISDK.Service
  127. yield* addPlugin()
  128. yield* aisdk.runSDK({
  129. model: ModelV2.Info.make({
  130. ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
  131. modelID: ModelV2.ID.make("openai/gpt-5"),
  132. package: "aisdk: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. expect(aiGatewayCalls).toHaveLength(1)
  145. expect(aiGatewayCalls[0]).toEqual({
  146. accountId: "env-account",
  147. gateway: "env-gateway",
  148. apiKey: "env-token",
  149. options: {
  150. metadata: { invoked_by: "test", project: "opencode" },
  151. cacheTtl: 300,
  152. cacheKey: "cache-key",
  153. skipCache: true,
  154. collectLog: false,
  155. headers: {
  156. "User-Agent": expect.stringContaining("opencode/"),
  157. },
  158. },
  159. })
  160. }),
  161. ),
  162. )
  163. it.effect("parses legacy cf-aig-metadata header when metadata option is absent", () =>
  164. withEnv(cloudflareEnv(), () =>
  165. Effect.gen(function* () {
  166. resetCalls()
  167. const plugin = yield* PluginV2.Service
  168. const aisdk = yield* AISDK.Service
  169. yield* addPlugin()
  170. yield* aisdk.runSDK({
  171. model: ModelV2.Info.make({
  172. ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
  173. modelID: ModelV2.ID.make("openai/gpt-5"),
  174. package: "aisdk:test-provider",
  175. }),
  176. package: "ai-gateway-provider",
  177. options: {
  178. name: "cloudflare-ai-gateway",
  179. headers: {
  180. "cf-aig-metadata": JSON.stringify({ invoked_by: "header", project: "opencode" }),
  181. },
  182. },
  183. })
  184. expect(aiGatewayCalls[0]?.options).toMatchObject({
  185. metadata: { invoked_by: "header", project: "opencode" },
  186. })
  187. }),
  188. ),
  189. )
  190. it.effect("prefers Cloudflare env values over auth/config-derived options", () =>
  191. withEnv(cloudflareEnv(), () =>
  192. Effect.gen(function* () {
  193. resetCalls()
  194. const plugin = yield* PluginV2.Service
  195. const aisdk = yield* AISDK.Service
  196. yield* addPlugin()
  197. yield* aisdk.runSDK({
  198. model: ModelV2.Info.make({
  199. ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
  200. modelID: ModelV2.ID.make("openai/gpt-5"),
  201. package: "aisdk:test-provider",
  202. }),
  203. package: "ai-gateway-provider",
  204. options: {
  205. name: "cloudflare-ai-gateway",
  206. accountId: "auth-account",
  207. gateway: "auth-gateway",
  208. apiKey: "auth-token",
  209. },
  210. })
  211. expect(aiGatewayCalls[0]).toMatchObject({
  212. accountId: "env-account",
  213. gateway: "env-gateway",
  214. apiKey: "env-token",
  215. })
  216. }),
  217. ),
  218. )
  219. it.effect("accepts gatewayId metadata copied from auth into provider options", () =>
  220. withEnv(
  221. cloudflareEnv({
  222. CLOUDFLARE_ACCOUNT_ID: undefined,
  223. CLOUDFLARE_GATEWAY_ID: undefined,
  224. CLOUDFLARE_API_TOKEN: undefined,
  225. }),
  226. () =>
  227. Effect.gen(function* () {
  228. resetCalls()
  229. const plugin = yield* PluginV2.Service
  230. const aisdk = yield* AISDK.Service
  231. yield* addPlugin()
  232. yield* aisdk.runSDK({
  233. model: ModelV2.Info.make({
  234. ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
  235. modelID: ModelV2.ID.make("openai/gpt-5"),
  236. package: "aisdk:test-provider",
  237. }),
  238. package: "ai-gateway-provider",
  239. options: {
  240. name: "cloudflare-ai-gateway",
  241. accountId: "auth-account",
  242. gatewayId: "auth-gateway",
  243. apiKey: "auth-token",
  244. },
  245. })
  246. expect(aiGatewayCalls[0]).toMatchObject({
  247. accountId: "auth-account",
  248. gateway: "auth-gateway",
  249. apiKey: "auth-token",
  250. })
  251. }),
  252. ),
  253. )
  254. it.effect("falls back to CF_AIG_TOKEN when CLOUDFLARE_API_TOKEN is unset", () =>
  255. withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: "cf-aig-token" }), () =>
  256. Effect.gen(function* () {
  257. resetCalls()
  258. const plugin = yield* PluginV2.Service
  259. const aisdk = yield* AISDK.Service
  260. yield* addPlugin()
  261. yield* aisdk.runSDK({
  262. model: ModelV2.Info.make({
  263. ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
  264. modelID: ModelV2.ID.make("openai/gpt-5"),
  265. package: "aisdk:test-provider",
  266. }),
  267. package: "ai-gateway-provider",
  268. options: { name: "cloudflare-ai-gateway" },
  269. })
  270. expect(aiGatewayCalls[0]).toMatchObject({ apiKey: "cf-aig-token" })
  271. }),
  272. ),
  273. )
  274. it.effect("does not create an SDK when account and gateway IDs are missing", () =>
  275. withEnv(cloudflareEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }), () =>
  276. Effect.gen(function* () {
  277. resetCalls()
  278. const plugin = yield* PluginV2.Service
  279. const aisdk = yield* AISDK.Service
  280. yield* addPlugin()
  281. const result = yield* aisdk.runSDK({
  282. model: ModelV2.Info.make({
  283. ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
  284. modelID: ModelV2.ID.make("openai/gpt-5"),
  285. package: "aisdk:test-provider",
  286. }),
  287. package: "ai-gateway-provider",
  288. options: { name: "cloudflare-ai-gateway" },
  289. })
  290. expect(result.sdk).toBeUndefined()
  291. expect(aiGatewayCalls).toHaveLength(0)
  292. }),
  293. ),
  294. )
  295. it.effect("does not create an SDK when the token is missing", () =>
  296. withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: undefined }), () =>
  297. Effect.gen(function* () {
  298. resetCalls()
  299. const plugin = yield* PluginV2.Service
  300. const aisdk = yield* AISDK.Service
  301. yield* addPlugin()
  302. const result = yield* aisdk.runSDK({
  303. model: ModelV2.Info.make({
  304. ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
  305. modelID: ModelV2.ID.make("openai/gpt-5"),
  306. package: "aisdk:test-provider",
  307. }),
  308. package: "ai-gateway-provider",
  309. options: { name: "cloudflare-ai-gateway" },
  310. })
  311. expect(result.sdk).toBeUndefined()
  312. expect(aiGatewayCalls).toHaveLength(0)
  313. }),
  314. ),
  315. )
  316. it.effect("does not replace a configured baseURL with the Cloudflare AI Gateway SDK", () =>
  317. withEnv(
  318. cloudflareEnv({
  319. CLOUDFLARE_ACCOUNT_ID: undefined,
  320. CLOUDFLARE_GATEWAY_ID: undefined,
  321. CLOUDFLARE_API_TOKEN: undefined,
  322. }),
  323. () =>
  324. Effect.gen(function* () {
  325. resetCalls()
  326. const plugin = yield* PluginV2.Service
  327. const aisdk = yield* AISDK.Service
  328. yield* addPlugin()
  329. const result = yield* aisdk.runSDK({
  330. model: ModelV2.Info.make({
  331. ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
  332. modelID: ModelV2.ID.make("openai/gpt-5"),
  333. package: "aisdk:test-provider",
  334. }),
  335. package: "ai-gateway-provider",
  336. options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" },
  337. })
  338. expect(result.sdk).toBeUndefined()
  339. expect(aiGatewayCalls).toHaveLength(0)
  340. }),
  341. ),
  342. )
  343. it.effect("maps provider/model IDs through the unified Cloudflare provider unchanged", () =>
  344. withEnv(cloudflareEnv(), () =>
  345. Effect.gen(function* () {
  346. resetCalls()
  347. const plugin = yield* PluginV2.Service
  348. const aisdk = yield* AISDK.Service
  349. yield* addPlugin()
  350. const result = yield* aisdk.runSDK({
  351. model: ModelV2.Info.make({
  352. ...ModelV2.Info.empty(
  353. ProviderV2.ID.make("cloudflare-ai-gateway"),
  354. ModelV2.ID.make("anthropic/claude-sonnet-4-5"),
  355. ),
  356. modelID: ModelV2.ID.make("anthropic/claude-sonnet-4-5"),
  357. package: "aisdk:test-provider",
  358. }),
  359. package: "ai-gateway-provider",
  360. options: { name: "cloudflare-ai-gateway" },
  361. })
  362. expect(result.sdk.languageModel("anthropic/claude-sonnet-4-5")).toEqual({
  363. modelId: { unifiedModelID: "anthropic/claude-sonnet-4-5" },
  364. provider: "cloudflare-ai-gateway",
  365. specificationVersion: "v3",
  366. })
  367. expect(unifiedCalls).toEqual(["anthropic/claude-sonnet-4-5"])
  368. expect(gatewayModelCalls).toEqual([{ unifiedModelID: "anthropic/claude-sonnet-4-5" }])
  369. }),
  370. ),
  371. )
  372. it.effect("ignores non Cloudflare AI Gateway packages", () =>
  373. withEnv(cloudflareEnv(), () =>
  374. Effect.gen(function* () {
  375. resetCalls()
  376. const plugin = yield* PluginV2.Service
  377. const aisdk = yield* AISDK.Service
  378. yield* addPlugin()
  379. const result = yield* aisdk.runSDK({
  380. model: ModelV2.Info.make({
  381. ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
  382. modelID: ModelV2.ID.make("openai/gpt-5"),
  383. package: "aisdk:test-provider",
  384. }),
  385. package: "@ai-sdk/openai-compatible",
  386. options: { name: "cloudflare-ai-gateway" },
  387. })
  388. expect(result.sdk).toBeUndefined()
  389. expect(aiGatewayCalls).toHaveLength(0)
  390. }),
  391. ),
  392. )
  393. })