Kaynağa Gözat

refactor(core): use native Cloudflare provider

Aiden Cline 4 gün önce
ebeveyn
işleme
d40518275a

+ 19 - 0
packages/ai/src/providers/cloudflare-workers-ai.ts

@@ -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 - 0
packages/ai/test/provider/cloudflare-workers-ai.test.ts

@@ -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")
+  })
+})

+ 23 - 38
packages/core/src/plugin/provider/cloudflare-workers-ai.ts

@@ -1,22 +1,18 @@
 import os from "os"
 import { App } from "../../app"
-import { Effect, Semaphore, Stream } from "effect"
+import { Effect } from "effect"
 import { define } from "@opencode-ai/plugin/effect/plugin"
 import { Form } from "@opencode-ai/schema/form"
-import { Bus } from "../../bus"
-import { Integration } from "../../integration"
 import { Provider } from "../../provider"
 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 bus = yield* Bus.Service
-    const loading = Semaphore.makeUnsafe(1)
-    const loaded: { accountId?: string } = {}
     const configured = yield* configuredSettings(providerID)
     const form = iife(() => {
       if (hasExplicitEndpoint(configured?.baseURL) || resolveAccountId(configured ?? {})) return
@@ -30,14 +26,6 @@ export const CloudflareWorkersAIPlugin = define({
         },
       ])
     })
-    const load = Effect.fn("CloudflareWorkersAIPlugin.load")(function* () {
-      const connection = yield* ctx.integration.connection.active(providerID)
-      const credential = connection
-        ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
-        : undefined
-      loaded.accountId =
-        credential?.type === "key" ? stringOption(credential.configuration ?? {}, "accountId") : undefined
-    })
     yield* ctx.integration.transform((draft) => {
       draft.method.update({
         integrationID: providerID,
@@ -48,29 +36,25 @@ export const CloudflareWorkersAIPlugin = define({
         },
       })
     })
-    yield* load()
     yield* ctx.catalog.transform((evt) => {
       const item = evt.provider.get(providerID)
       if (!item) return
-      const accountId = resolveAccountId(configured ?? {}, loaded.accountId)
-      if (!accountId) 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
-        const baseURL = provider.settings?.baseURL
-        if (hasExplicitEndpoint(baseURL)) return
-        provider.settings = {
-          ...provider.settings,
-          baseURL: typeof baseURL === "string" ? expandAccountId(baseURL, accountId) : workersEndpoint(accountId),
-        }
+        if (!compatible) return
+        provider.package = nativePackage
+        provider.settings = nativeSettings(provider.settings)
       })
       for (const model of item.models.values()) {
-        if (typeof model.settings?.baseURL !== "string") continue
-        const modelAccountId = resolveAccountId(model.settings, accountId)
         evt.model.update(item.provider.id, model.id, (draft) => {
-          draft.settings = {
-            ...draft.settings,
-            baseURL: expandAccountId(draft.settings?.baseURL, modelAccountId),
-          }
+          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)
         })
       }
     })
@@ -101,17 +85,11 @@ export const CloudflareWorkersAIPlugin = define({
         evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id)
       }),
     )
-    const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
-    yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
-      Stream.filter((event) => event.data.integrationID === Integration.ID.make(providerID)),
-      Stream.runForEach(refresh),
-      Effect.forkScoped({ startImmediately: true }),
-    )
   }),
 })
 
-function resolveAccountId(options: Record<string, unknown>, connected?: string) {
-  return process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId") ?? connected
+function resolveAccountId(options: Record<string, unknown>) {
+  return process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
 }
 
 function workersEndpoint(accountId: string) {
@@ -122,6 +100,13 @@ 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>>

+ 31 - 85
packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts

@@ -1,10 +1,9 @@
 import { AISDK } from "@opencode-ai/core/aisdk"
 import { describe, expect } from "bun:test"
-import { Effect, Fiber, Stream } from "effect"
-import { TestClock } from "effect/testing"
-import { Bus } from "@opencode-ai/core/bus"
+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"
@@ -19,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)
 })
@@ -106,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" }, () =>
       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()
@@ -122,21 +118,10 @@ describe("CloudflareWorkersAIPlugin", () => {
           (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()
       }),
     ),
   )
@@ -196,30 +181,32 @@ 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("reloads provider and model endpoints from a connected account ID", () =>
+  it.effect("passes the connected account ID to the native provider at runtime", () =>
     withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined }, () =>
       Effect.gen(function* () {
-        const bus = yield* Bus.Service
         const catalog = yield* Catalog.Service
-        const integrations = yield* Integration.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",
             }
           })
@@ -232,70 +219,29 @@ describe("CloudflareWorkersAIPlugin", () => {
         })
         yield* addPlugin()
 
-        const updated = yield* bus
-          .subscribe(Catalog.Event.Updated)
-          .pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
-        yield* Effect.yieldNow
-        yield* integrations.connection.key({
-          integrationID: Integration.ID.make(providerID),
-          key: "secret",
-          answer: { accountId: "connected-acct" },
-        })
-        yield* Effect.yieldNow
-        yield* TestClock.adjust(500)
-        yield* Fiber.join(updated)
-
-        expect(required(yield* catalog.provider.get(providerID)).settings?.baseURL).toBe(
-          "https://api.cloudflare.com/client/v4/accounts/connected-acct/ai/v1",
-        )
-        expect(required(yield* catalog.model.get(providerID, Model.ID.make("@cf/model"))).settings?.baseURL).toBe(
-          "https://api.cloudflare.com/client/v4/accounts/model-acct/ai/v1",
-        )
-      }),
-    ),
-  )
-
-  it.effect("loads a connected account at startup and restores the template after removal", () =>
-    withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined }, () =>
-      Effect.gen(function* () {
-        const bus = yield* Bus.Service
-        const catalog = yield* Catalog.Service
-        const credentials = yield* Credential.Service
-        const integrations = yield* Integration.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 = {
-              baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
-            }
-          }),
-        )
-        const credential = yield* credentials.create({
-          integrationID: Integration.ID.make(providerID),
-          value: Credential.Key.make({
+        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: "startup-acct" },
+            configuration: { accountId: "connected-acct" },
           }),
-        })
-        yield* addPlugin()
-
-        expect(required(yield* catalog.provider.get(providerID)).settings?.baseURL).toBe(
-          "https://api.cloudflare.com/client/v4/accounts/startup-acct/ai/v1",
+          { loadPackage: () => Effect.succeed({ model }) },
         )
 
-        const updated = yield* bus
-          .subscribe(Catalog.Event.Updated)
-          .pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
-        yield* Effect.yieldNow
-        yield* integrations.connection.remove(credential.id)
-        yield* Effect.yieldNow
-        yield* TestClock.adjust(500)
-        yield* Fiber.join(updated)
-
-        expect(required(yield* catalog.provider.get(providerID)).settings?.baseURL).toBe(
-          "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
+        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",
         )
       }),
     ),