import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway" import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service const host = yield* PluginHost.make(plugin) yield* CloudflareAIGatewayPlugin.effect(host) }) function withEnv(vars: Record, fx: () => Effect.Effect) { return Effect.acquireUseRelease( Effect.sync(() => { const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) Object.entries(vars).forEach(([key, value]) => { if (value === undefined) delete process.env[key] else process.env[key] = value }) return previous }), fx, (previous) => Effect.sync(() => { Object.entries(previous).forEach(([key, value]) => { if (value === undefined) delete process.env[key] else process.env[key] = value }) }), ) } const aiGatewayCalls: Record[] = [] const unifiedCalls: string[] = [] const gatewayModelCalls: unknown[] = [] function captureAiGatewayOptions(options: Record) { const nested = options.options && typeof options.options === "object" ? (options.options as Record) : undefined return { ...options, ...(nested ? { options: { ...nested, headers: nested.headers && typeof nested.headers === "object" ? { ...(nested.headers as Record) } : nested.headers, }, } : {}), } } function resetCalls() { aiGatewayCalls.length = 0 unifiedCalls.length = 0 gatewayModelCalls.length = 0 } function cloudflareEnv(overrides: Record = {}) { return { CLOUDFLARE_ACCOUNT_ID: "env-account", CLOUDFLARE_GATEWAY_ID: "env-gateway", CLOUDFLARE_API_TOKEN: "env-token", CF_AIG_TOKEN: undefined, ...overrides, } } mock.module("ai-gateway-provider", () => ({ createAiGateway(options: Record) { aiGatewayCalls.push(captureAiGatewayOptions(options)) return (input: unknown) => { gatewayModelCalls.push(input) return { modelId: input, provider: "cloudflare-ai-gateway", specificationVersion: "v3", } } }, })) mock.module("ai-gateway-provider/providers/unified", () => ({ createUnified() { return (modelID: string) => { unifiedCalls.push(modelID) return { unifiedModelID: modelID } } }, })) describe("CloudflareAIGatewayPlugin", () => { it.effect("registers a gateway token method and stores prompted gateway metadata", () => withEnv( cloudflareEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined, CLOUDFLARE_API_TOKEN: undefined, }), () => Effect.gen(function* () { const integrations = yield* Integration.Service yield* addPlugin() const integrationID = Integration.ID.make("cloudflare-ai-gateway") expect((yield* integrations.get(integrationID))?.methods).toEqual([ { type: "key", label: "Gateway API token", prompts: [ { type: "text", key: "accountId", message: "Enter your Cloudflare Account ID", placeholder: "e.g. 1234567890abcdef1234567890abcdef", }, { type: "text", key: "gatewayId", message: "Enter your Cloudflare AI Gateway ID", placeholder: "e.g. my-gateway", }, ], }, ]) yield* integrations.connection.key({ integrationID, key: "secret", inputs: { accountId: "acct", gatewayId: "gateway" }, }) const connection = yield* integrations.connection.active(integrationID) if (!connection) throw new Error("Expected connection") expect(yield* integrations.connection.resolve(connection)).toMatchObject({ type: "key", key: "secret", metadata: { accountId: "acct", gatewayId: "gateway" }, }) }), ), ) it.effect("omits gateway prompts backed by Cloudflare env", () => withEnv(cloudflareEnv(), () => Effect.gen(function* () { const integrations = yield* Integration.Service yield* addPlugin() expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({ type: "key", label: "Gateway API token", prompts: [], }) }), ), ) it.effect("requires account, gateway, and token before creating the unified SDK", () => withEnv( { CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_GATEWAY_ID: "gateway", CLOUDFLARE_API_TOKEN: "token", CF_AIG_TOKEN: undefined, }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, }) expect(result.sdk.languageModel("openai/gpt-5")).toBeDefined() }), ), ) it.effect("passes legacy metadata, cache, log, and User-Agent values under the AI Gateway options key", () => withEnv(cloudflareEnv(), () => Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service yield* addPlugin() yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway", metadata: { invoked_by: "test", project: "opencode" }, cacheTtl: 300, cacheKey: "cache-key", skipCache: true, collectLog: false, }, }) expect(aiGatewayCalls).toHaveLength(1) expect(aiGatewayCalls[0]).toEqual({ accountId: "env-account", gateway: "env-gateway", apiKey: "env-token", options: { metadata: { invoked_by: "test", project: "opencode" }, cacheTtl: 300, cacheKey: "cache-key", skipCache: true, collectLog: false, headers: { "User-Agent": expect.stringContaining("opencode/"), }, }, }) }), ), ) it.effect("parses legacy cf-aig-metadata header when metadata option is absent", () => withEnv(cloudflareEnv(), () => Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service yield* addPlugin() yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway", headers: { "cf-aig-metadata": JSON.stringify({ invoked_by: "header", project: "opencode" }), }, }, }) expect(aiGatewayCalls[0]?.options).toMatchObject({ metadata: { invoked_by: "header", project: "opencode" }, }) }), ), ) it.effect("prefers Cloudflare env values over auth/config-derived options", () => withEnv(cloudflareEnv(), () => Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service yield* addPlugin() yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway", accountId: "auth-account", gateway: "auth-gateway", apiKey: "auth-token", }, }) expect(aiGatewayCalls[0]).toMatchObject({ accountId: "env-account", gateway: "env-gateway", apiKey: "env-token", }) }), ), ) it.effect("accepts gatewayId metadata copied from auth into provider options", () => withEnv( cloudflareEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined, CLOUDFLARE_API_TOKEN: undefined, }), () => Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service yield* addPlugin() yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway", accountId: "auth-account", gatewayId: "auth-gateway", apiKey: "auth-token", }, }) expect(aiGatewayCalls[0]).toMatchObject({ accountId: "auth-account", gateway: "auth-gateway", apiKey: "auth-token", }) }), ), ) it.effect("falls back to CF_AIG_TOKEN when CLOUDFLARE_API_TOKEN is unset", () => withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: "cf-aig-token" }), () => Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service yield* addPlugin() yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, }) expect(aiGatewayCalls[0]).toMatchObject({ apiKey: "cf-aig-token" }) }), ), ) it.effect("does not create an SDK when account and gateway IDs are missing", () => withEnv(cloudflareEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }), () => Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, }) expect(result.sdk).toBeUndefined() expect(aiGatewayCalls).toHaveLength(0) }), ), ) it.effect("does not create an SDK when the token is missing", () => withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: undefined }), () => Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, }) expect(result.sdk).toBeUndefined() expect(aiGatewayCalls).toHaveLength(0) }), ), ) it.effect("does not replace a configured baseURL with the Cloudflare AI Gateway SDK", () => withEnv( cloudflareEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined, CLOUDFLARE_API_TOKEN: undefined, }), () => Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" }, }) expect(result.sdk).toBeUndefined() expect(aiGatewayCalls).toHaveLength(0) }), ), ) it.effect("maps provider/model IDs through the unified Cloudflare provider unchanged", () => withEnv(cloudflareEnv(), () => Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty( ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("anthropic/claude-sonnet-4-5"), ), modelID: ModelV2.ID.make("anthropic/claude-sonnet-4-5"), package: "aisdk:test-provider", }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, }) expect(result.sdk.languageModel("anthropic/claude-sonnet-4-5")).toEqual({ modelId: { unifiedModelID: "anthropic/claude-sonnet-4-5" }, provider: "cloudflare-ai-gateway", specificationVersion: "v3", }) expect(unifiedCalls).toEqual(["anthropic/claude-sonnet-4-5"]) expect(gatewayModelCalls).toEqual([{ unifiedModelID: "anthropic/claude-sonnet-4-5" }]) }), ), ) it.effect("ignores non Cloudflare AI Gateway packages", () => withEnv(cloudflareEnv(), () => Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), package: "@ai-sdk/openai-compatible", options: { name: "cloudflare-ai-gateway" }, }) expect(result.sdk).toBeUndefined() expect(aiGatewayCalls).toHaveLength(0) }), ), ) })