Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/ai/src/providers/cloudflare-workers-ai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { ProviderPackage } from "../provider-package"
import type { OpenAIProviderOptionsInput } from "./openai-options"
import { CloudflareWorkersAI } from "./cloudflare"

export interface Settings extends ProviderPackage.Settings {
readonly accountId?: string
readonly apiKey?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}

export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
CloudflareWorkersAI.configure({
...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : { accountId: settings.accountId ?? "" }),
apiKey: settings.apiKey,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
16 changes: 16 additions & 0 deletions packages/ai/test/provider/cloudflare-workers-ai.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test"
import { model } from "../../src/providers/cloudflare-workers-ai"

describe("Cloudflare Workers AI provider package", () => {
test("derives the endpoint from accountId", () => {
const resolved = model("@cf/model", { accountId: "account", apiKey: "secret" })

expect(resolved.route.endpoint.baseURL).toBe("https://api.cloudflare.com/client/v4/accounts/account/ai/v1")
})

test("preserves an explicit endpoint", () => {
const resolved = model("@cf/model", { baseURL: "https://proxy.example/v1", apiKey: "secret" })

expect(resolved.route.endpoint.baseURL).toBe("https://proxy.example/v1")
})
})
40 changes: 32 additions & 8 deletions packages/core/src/plugin/provider/cloudflare-workers-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ import { iife } from "../../util/iife"
import { configuredSettings } from "./configured"

const providerID = Provider.ID.make("cloudflare-workers-ai")
const nativePackage = "@opencode-ai/ai/providers/cloudflare-workers-ai"

export const CloudflareWorkersAIPlugin = define({
id: "opencode.provider.cloudflare-workers-ai",
effect: Effect.fn(function* (ctx) {
const configured = yield* configuredSettings(providerID)
const form = iife(() => {
if (typeof configured?.baseURL === "string" || resolveAccountId(configured ?? {})) return
if (hasExplicitEndpoint(configured?.baseURL) || resolveAccountId(configured ?? {})) return
return Form.Fields.make([
{
type: "string",
Expand All @@ -38,12 +39,24 @@ export const CloudflareWorkersAIPlugin = define({
yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(providerID)
if (!item) return
const compatible =
Provider.isAISDK(item.provider.package) &&
Provider.packageName(item.provider.package) === "@ai-sdk/openai-compatible"
evt.provider.update(item.provider.id, (provider) => {
if (!Provider.isAISDK(provider.package)) return
if (typeof provider.settings?.baseURL === "string") return
const accountId = resolveAccountId(provider.settings ?? {})
if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) }
if (!compatible) return
provider.package = nativePackage
provider.settings = nativeSettings(provider.settings)
})
for (const model of item.models.values()) {
evt.model.update(item.provider.id, model.id, (draft) => {
if (!draft.package && !compatible) return
if (draft.package === nativePackage) return
if (draft.package && !Provider.isAISDK(draft.package)) return
if (draft.package && Provider.packageName(draft.package) !== "@ai-sdk/openai-compatible") return
if (draft.package) draft.package = nativePackage
draft.settings = nativeSettings(draft.settings)
})
}
})
yield* ctx.aisdk.hook(
"sdk",
Expand Down Expand Up @@ -83,6 +96,17 @@ function workersEndpoint(accountId: string) {
return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`
}

function hasExplicitEndpoint(baseURL: unknown) {
return typeof baseURL === "string" && !baseURL.includes("${CLOUDFLARE_ACCOUNT_ID}")
}

function nativeSettings(settings: Record<string, unknown> | undefined) {
const result = { ...settings }
if (process.env.CLOUDFLARE_ACCOUNT_ID) result.baseURL = workersEndpoint(process.env.CLOUDFLARE_ACCOUNT_ID)
else if (!hasExplicitEndpoint(result.baseURL)) delete result.baseURL
return result
}

function hasWorkersEndpoint(model: {
readonly package?: string
readonly settings?: Readonly<Record<string, unknown>>
Expand All @@ -93,7 +117,7 @@ function hasWorkersEndpoint(model: {
function sdkOptions(options: Record<string, any>, app: App.Info) {
return {
...options,
baseURL: expandAccountId(options.baseURL),
baseURL: expandAccountId(options.baseURL, resolveAccountId(options)),
apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey,
headers: {
"User-Agent": `${App.useragent(app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
Expand All @@ -103,9 +127,9 @@ function sdkOptions(options: Record<string, any>, app: App.Info) {
}
}

function expandAccountId(baseURL: unknown) {
function expandAccountId(baseURL: unknown, accountId: string | undefined) {
if (typeof baseURL !== "string") return baseURL
return baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", process.env.CLOUDFLARE_ACCOUNT_ID ?? "${CLOUDFLARE_ACCOUNT_ID}")
return baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", accountId ?? "${CLOUDFLARE_ACCOUNT_ID}")
}

function stringOption(options: Record<string, unknown>, key: string) {
Expand Down
81 changes: 61 additions & 20 deletions packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
Expand All @@ -16,7 +18,6 @@ const it = testEffect(PluginTestLayer)

const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* CloudflareWorkersAIPlugin.effect(host)
})
Expand Down Expand Up @@ -103,37 +104,24 @@ describe("CloudflareWorkersAIPlugin", () => {
),
)

it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () =>
it.effect("maps the environment account ID to the native endpoint", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
provider.package = Provider.aisdk("test-provider")
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
}),
)
yield* addPlugin()
expect(
(yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-workers-ai")))?.methods,
).toContainEqual({ type: "key", label: "API key" })
const provider = required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))
const sdk = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
modelID: Model.ID.make("@cf/model"),
package: provider.package,
settings: provider.settings,
}),
package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-workers-ai", headers: { custom: "header" } },
})
expect(provider).toMatchObject({
package: "aisdk:test-provider",
package: "@opencode-ai/ai/providers/cloudflare-workers-ai",
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1" },
})
expect(sdk.sdk).toBeDefined()
}),
),
)
Expand Down Expand Up @@ -193,19 +181,72 @@ describe("CloudflareWorkersAIPlugin", () => {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
provider.package = Provider.aisdk("test-provider")
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { ...provider.settings, accountId: "configured-acct" }
}),
)
yield* addPlugin()
expect(required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))).toMatchObject({
package: "aisdk:test-provider",
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1" },
package: "@opencode-ai/ai/providers/cloudflare-workers-ai",
settings: {
accountId: "configured-acct",
baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1",
},
})
}),
),
)

it.effect("passes the connected account ID to the native provider at runtime", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined }, () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("cloudflare-workers-ai")
yield* catalog.transform((draft) => {
draft.provider.update(providerID, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = {
accountId: "configured-acct",
baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
}
})
draft.model.update(providerID, Model.ID.make("@cf/model"), (model) => {
model.settings = {
accountId: "model-acct",
baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
}
})
})
yield* addPlugin()

const selected = required(yield* catalog.model.get(providerID, Model.ID.make("@cf/model")))
const { model } = yield* Effect.promise(() => import("@opencode-ai/ai/providers/cloudflare-workers-ai"))
const resolved = yield* ModelResolver.fromCatalogModel(
selected,
Credential.Key.make({
type: "key",
key: "secret",
configuration: { accountId: "connected-acct" },
}),
{ loadPackage: () => Effect.succeed({ model }) },
)

expect(required(yield* catalog.provider.get(providerID))).toMatchObject({
package: "@opencode-ai/ai/providers/cloudflare-workers-ai",
settings: { accountId: "configured-acct" },
})
expect(selected).toMatchObject({
package: "@opencode-ai/ai/providers/cloudflare-workers-ai",
settings: { accountId: "model-acct" },
})
expect(selected.settings).not.toHaveProperty("baseURL")
expect(resolved.route.endpoint.baseURL).toBe(
"https://api.cloudflare.com/client/v4/accounts/connected-acct/ai/v1",
)
}),
),
)

it.effect("uses env API key over auth or configured API key and keeps the Cloudflare User-Agent", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () =>
Effect.gen(function* () {
Expand Down
Loading