opencode/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts

431 lines
14 KiB
TypeScript

import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Model } from "@opencode-ai/core/model"
import { Plugin } 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 { Provider } from "@opencode-ai/core/provider"
import { withEnv } from "../fixture/env"
import { Integration } from "@opencode-ai/core/integration"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* CloudflareAIGatewayPlugin.effect(host)
})
const aiGatewayCalls: Record<string, unknown>[] = []
const unifiedCalls: string[] = []
const gatewayModelCalls: unknown[] = []
function captureAiGatewayOptions(options: Record<string, unknown>) {
const nested =
options.options && typeof options.options === "object" ? (options.options as Record<string, unknown>) : undefined
return {
...options,
...(nested
? {
options: {
...nested,
headers:
nested.headers && typeof nested.headers === "object"
? { ...(nested.headers as Record<string, unknown>) }
: nested.headers,
},
}
: {}),
}
}
function resetCalls() {
aiGatewayCalls.length = 0
unifiedCalls.length = 0
gatewayModelCalls.length = 0
}
function cloudflareEnv(overrides: Record<string, string | undefined> = {}) {
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<string, unknown>) {
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 account and gateway forms when the environment does not provide them", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }, () =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({
type: "key",
label: "Gateway API token",
form: [
expect.objectContaining({ type: "string", key: "accountId", required: true }),
expect.objectContaining({ type: "string", key: "gatewayId", required: true }),
],
})
}),
),
)
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 aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
modelID: Model.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 aisdk = yield* AISDK.Service
yield* addPlugin()
yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
modelID: Model.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 aisdk = yield* AISDK.Service
yield* addPlugin()
yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
modelID: Model.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 aisdk = yield* AISDK.Service
yield* addPlugin()
yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
modelID: Model.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 aisdk = yield* AISDK.Service
yield* addPlugin()
yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
modelID: Model.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 aisdk = yield* AISDK.Service
yield* addPlugin()
yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
modelID: Model.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 aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
modelID: Model.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 aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
modelID: Model.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 aisdk = yield* AISDK.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(Provider.ID.make("cloudflare-ai-gateway"), (provider) => {
provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" }
}),
)
yield* addPlugin()
const integrations = yield* Integration.Service
expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({
type: "key",
label: "Gateway API token",
})
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
modelID: Model.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 aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(
Provider.ID.make("cloudflare-ai-gateway"),
Model.ID.make("anthropic/claude-sonnet-4-5"),
),
modelID: Model.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 aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-ai-gateway"), Model.ID.make("openai/gpt-5")),
modelID: Model.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)
}),
),
)
})