mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-13 23:09:50 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d40518275a | |||
| f0f72865fc | |||
| ef3f961fe9 |
@@ -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)
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,13 +8,14 @@ import { iife } from "../../util/iife"
|
|||||||
import { configuredSettings } from "./configured"
|
import { configuredSettings } from "./configured"
|
||||||
|
|
||||||
const providerID = Provider.ID.make("cloudflare-workers-ai")
|
const providerID = Provider.ID.make("cloudflare-workers-ai")
|
||||||
|
const nativePackage = "@opencode-ai/ai/providers/cloudflare-workers-ai"
|
||||||
|
|
||||||
export const CloudflareWorkersAIPlugin = define({
|
export const CloudflareWorkersAIPlugin = define({
|
||||||
id: "opencode.provider.cloudflare-workers-ai",
|
id: "opencode.provider.cloudflare-workers-ai",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
const configured = yield* configuredSettings(providerID)
|
const configured = yield* configuredSettings(providerID)
|
||||||
const form = iife(() => {
|
const form = iife(() => {
|
||||||
if (typeof configured?.baseURL === "string" || resolveAccountId(configured ?? {})) return
|
if (hasExplicitEndpoint(configured?.baseURL) || resolveAccountId(configured ?? {})) return
|
||||||
return Form.Fields.make([
|
return Form.Fields.make([
|
||||||
{
|
{
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -38,12 +39,24 @@ export const CloudflareWorkersAIPlugin = define({
|
|||||||
yield* ctx.catalog.transform((evt) => {
|
yield* ctx.catalog.transform((evt) => {
|
||||||
const item = evt.provider.get(providerID)
|
const item = evt.provider.get(providerID)
|
||||||
if (!item) return
|
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) => {
|
evt.provider.update(item.provider.id, (provider) => {
|
||||||
if (!Provider.isAISDK(provider.package)) return
|
if (!compatible) return
|
||||||
if (typeof provider.settings?.baseURL === "string") return
|
provider.package = nativePackage
|
||||||
const accountId = resolveAccountId(provider.settings ?? {})
|
provider.settings = nativeSettings(provider.settings)
|
||||||
if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) }
|
|
||||||
})
|
})
|
||||||
|
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(
|
yield* ctx.aisdk.hook(
|
||||||
"sdk",
|
"sdk",
|
||||||
@@ -83,6 +96,17 @@ function workersEndpoint(accountId: string) {
|
|||||||
return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`
|
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: {
|
function hasWorkersEndpoint(model: {
|
||||||
readonly package?: string
|
readonly package?: string
|
||||||
readonly settings?: Readonly<Record<string, unknown>>
|
readonly settings?: Readonly<Record<string, unknown>>
|
||||||
@@ -93,7 +117,7 @@ function hasWorkersEndpoint(model: {
|
|||||||
function sdkOptions(options: Record<string, any>, app: App.Info) {
|
function sdkOptions(options: Record<string, any>, app: App.Info) {
|
||||||
return {
|
return {
|
||||||
...options,
|
...options,
|
||||||
baseURL: expandAccountId(options.baseURL),
|
baseURL: expandAccountId(options.baseURL, resolveAccountId(options)),
|
||||||
apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey,
|
apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey,
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent": `${App.useragent(app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
|
"User-Agent": `${App.useragent(app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||||
@@ -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
|
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) {
|
function stringOption(options: Record<string, unknown>, key: string) {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { AISDK } from "@opencode-ai/core/aisdk"
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
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 { Model } from "@opencode-ai/core/model"
|
||||||
import { Plugin } from "@opencode-ai/core/plugin"
|
import { Plugin } from "@opencode-ai/core/plugin"
|
||||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
@@ -16,7 +18,6 @@ const it = testEffect(PluginTestLayer)
|
|||||||
|
|
||||||
const addPlugin = Effect.fn(function* () {
|
const addPlugin = Effect.fn(function* () {
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
const aisdk = yield* AISDK.Service
|
|
||||||
const host = yield* PluginHost.make(plugin)
|
const host = yield* PluginHost.make(plugin)
|
||||||
yield* CloudflareWorkersAIPlugin.effect(host)
|
yield* CloudflareWorkersAIPlugin.effect(host)
|
||||||
})
|
})
|
||||||
@@ -103,15 +104,13 @@ 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" }, () =>
|
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
const aisdk = yield* AISDK.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* catalog.transform((catalog) =>
|
yield* catalog.transform((catalog) =>
|
||||||
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
|
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()
|
yield* addPlugin()
|
||||||
@@ -119,21 +118,10 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||||||
(yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-workers-ai")))?.methods,
|
(yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-workers-ai")))?.methods,
|
||||||
).toContainEqual({ type: "key", label: "API key" })
|
).toContainEqual({ type: "key", label: "API key" })
|
||||||
const provider = required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))
|
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({
|
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" },
|
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1" },
|
||||||
})
|
})
|
||||||
expect(sdk.sdk).toBeDefined()
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -193,19 +181,72 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* catalog.transform((catalog) =>
|
yield* catalog.transform((catalog) =>
|
||||||
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
|
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" }
|
provider.settings = { ...provider.settings, accountId: "configured-acct" }
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* addPlugin()
|
yield* addPlugin()
|
||||||
expect(required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))).toMatchObject({
|
expect(required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))).toMatchObject({
|
||||||
package: "aisdk:test-provider",
|
package: "@opencode-ai/ai/providers/cloudflare-workers-ai",
|
||||||
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1" },
|
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", () =>
|
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" }, () =>
|
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
Reference in New Issue
Block a user