mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-08 10:09:52 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c0492cbc5 | |||
| 16f144465b | |||
| 478d0bd533 |
@@ -12,6 +12,7 @@ type GenericModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
|||||||
ProviderAuthOption<"optional"> & {
|
ProviderAuthOption<"optional"> & {
|
||||||
readonly provider?: string
|
readonly provider?: string
|
||||||
readonly baseURL: string
|
readonly baseURL: string
|
||||||
|
readonly queryParams?: Readonly<Record<string, string>>
|
||||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ export interface Settings extends ProviderPackage.Settings {
|
|||||||
readonly apiKey?: string
|
readonly apiKey?: string
|
||||||
readonly baseURL: string
|
readonly baseURL: string
|
||||||
readonly provider?: string
|
readonly provider?: string
|
||||||
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
|
readonly queryParams?: Readonly<Record<string, string>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||||
@@ -31,11 +34,11 @@ export const routes = [OpenAICompatibleChat.route]
|
|||||||
|
|
||||||
export const configure = (input: GenericModelOptions) => {
|
export const configure = (input: GenericModelOptions) => {
|
||||||
const provider = input.provider ?? "openai-compatible"
|
const provider = input.provider ?? "openai-compatible"
|
||||||
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
|
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, queryParams, ...rest } = input
|
||||||
const route = OpenAICompatibleChat.route.with({
|
const route = OpenAICompatibleChat.route.with({
|
||||||
...rest,
|
...rest,
|
||||||
provider,
|
provider,
|
||||||
endpoint: { baseURL },
|
endpoint: { baseURL, query: queryParams },
|
||||||
auth: AuthOptions.bearer(input, []),
|
auth: AuthOptions.bearer(input, []),
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
@@ -75,6 +78,8 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
|
|||||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
limits: settings.limits,
|
limits: settings.limits,
|
||||||
provider: settings.provider,
|
provider: settings.provider,
|
||||||
|
providerOptions: settings.providerOptions,
|
||||||
|
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
||||||
}).model(modelID)
|
}).model(modelID)
|
||||||
|
|
||||||
export const baseten = define(profiles.baseten)
|
export const baseten = define(profiles.baseten)
|
||||||
|
|||||||
@@ -64,6 +64,24 @@ describe("provider package entrypoints", () => {
|
|||||||
expect(xai.route.defaults.providerOptions).toMatchObject({ xai: { reasoningEffort: "high", store: false } })
|
expect(xai.route.defaults.providerOptions).toMatchObject({ xai: { reasoningEffort: "high", store: false } })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("maps OpenAI-compatible package settings onto the executable model", async () => {
|
||||||
|
const OpenAICompatible = await import("@opencode-ai/ai/providers/openai-compatible")
|
||||||
|
const selected = OpenAICompatible.model("custom-model", {
|
||||||
|
apiKey: "fixture",
|
||||||
|
baseURL: "https://provider.example.test/v1",
|
||||||
|
provider: "example",
|
||||||
|
queryParams: { version: "preview" },
|
||||||
|
providerOptions: { openai: { reasoningEffort: "high" } },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(String(selected.provider)).toBe("example")
|
||||||
|
expect(selected.route.endpoint).toMatchObject({
|
||||||
|
baseURL: "https://provider.example.test/v1",
|
||||||
|
query: { version: "preview" },
|
||||||
|
})
|
||||||
|
expect(selected.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high" } })
|
||||||
|
})
|
||||||
|
|
||||||
test("maps package settings onto the executable model", () => {
|
test("maps package settings onto the executable model", () => {
|
||||||
const selected = model("gpt-5", {
|
const selected = model("gpt-5", {
|
||||||
apiKey: "fixture",
|
apiKey: "fixture",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface Mapping {
|
|||||||
|
|
||||||
export interface MapInput {
|
export interface MapInput {
|
||||||
readonly packageName: string | undefined
|
readonly packageName: string | undefined
|
||||||
|
readonly providerID: string
|
||||||
readonly settings: Readonly<Record<string, unknown>>
|
readonly settings: Readonly<Record<string, unknown>>
|
||||||
readonly modelID: string
|
readonly modelID: string
|
||||||
}
|
}
|
||||||
@@ -51,6 +52,8 @@ export function map(input: MapInput): Mapping | undefined {
|
|||||||
...mapGoogleOptions(input.settings),
|
...mapGoogleOptions(input.settings),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
case "@ai-sdk/openai-compatible":
|
||||||
|
return mapOpenAICompatible(input, baseSettings)
|
||||||
case "@openrouter/ai-sdk-provider":
|
case "@openrouter/ai-sdk-provider":
|
||||||
return mapOpenRouter(input.settings, baseSettings)
|
return mapOpenRouter(input.settings, baseSettings)
|
||||||
case "@ai-sdk/xai":
|
case "@ai-sdk/xai":
|
||||||
@@ -63,6 +66,25 @@ export function map(input: MapInput): Mapping | undefined {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOpenAICompatible(
|
||||||
|
input: MapInput,
|
||||||
|
baseSettings: Readonly<Record<string, unknown>>,
|
||||||
|
): Mapping | undefined {
|
||||||
|
if (typeof baseSettings.baseURL !== "string") return undefined
|
||||||
|
return {
|
||||||
|
package: "@opencode-ai/ai/providers/openai-compatible",
|
||||||
|
settings: {
|
||||||
|
baseURL: baseSettings.baseURL,
|
||||||
|
...mapAPIKey(input.settings),
|
||||||
|
provider: input.providerID,
|
||||||
|
...(isStringRecord(input.settings.queryParams) ? { queryParams: input.settings.queryParams } : {}),
|
||||||
|
...mapOpenAIOptions(input.settings),
|
||||||
|
},
|
||||||
|
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapBedrockMantle(input: MapInput, baseSettings: Readonly<Record<string, unknown>>): Mapping | undefined {
|
function mapBedrockMantle(input: MapInput, baseSettings: Readonly<Record<string, unknown>>): Mapping | undefined {
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import { LanguageModel } from "@opencode-ai/ai"
|
|||||||
// ast-grep-ignore: no-star-import
|
// ast-grep-ignore: no-star-import
|
||||||
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
|
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
|
||||||
// ast-grep-ignore: no-star-import
|
// ast-grep-ignore: no-star-import
|
||||||
import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat"
|
|
||||||
// ast-grep-ignore: no-star-import
|
|
||||||
import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses"
|
import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses"
|
||||||
import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
|
import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
@@ -18,6 +16,7 @@ import { Credential } from "./credential"
|
|||||||
import { Integration } from "./integration"
|
import { Integration } from "./integration"
|
||||||
import { Capabilities, ID, Info, Ref, VariantID } from "./model"
|
import { Capabilities, ID, Info, Ref, VariantID } from "./model"
|
||||||
import { Npm } from "@opencode-ai/util/npm"
|
import { Npm } from "@opencode-ai/util/npm"
|
||||||
|
import { PluginHooks } from "./plugin/hooks"
|
||||||
import { Provider } from "./provider"
|
import { Provider } from "./provider"
|
||||||
|
|
||||||
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
|
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
|
||||||
@@ -135,6 +134,11 @@ export const withVariant = (
|
|||||||
export interface Dependencies {
|
export interface Dependencies {
|
||||||
readonly loadPackage?: (specifier: string) => Effect.Effect<Provider.ProviderPackage, Provider.LoadError>
|
readonly loadPackage?: (specifier: string) => Effect.Effect<Provider.ProviderPackage, Provider.LoadError>
|
||||||
readonly loadAISDK?: (model: Info) => Effect.Effect<LanguageModel, AISDK.InitError>
|
readonly loadAISDK?: (model: Info) => Effect.Effect<LanguageModel, AISDK.InitError>
|
||||||
|
readonly resolveProvider?: (input: {
|
||||||
|
readonly model: Info
|
||||||
|
readonly credential?: Credential.Value
|
||||||
|
readonly settings: Record<string, unknown>
|
||||||
|
}) => Effect.Effect<Record<string, unknown>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fromCatalogModel = (
|
export const fromCatalogModel = (
|
||||||
@@ -144,8 +148,6 @@ export const fromCatalogModel = (
|
|||||||
): Effect.Effect<LanguageModel, UnsupportedPackageError> => {
|
): Effect.Effect<LanguageModel, UnsupportedPackageError> => {
|
||||||
const resolved = produce(model, (draft) => {
|
const resolved = produce(model, (draft) => {
|
||||||
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
|
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
|
||||||
if (credential?.type === "key" && credential.metadata !== undefined)
|
|
||||||
draft.body = Provider.mergeOverlay(draft.body, credential.metadata)
|
|
||||||
})
|
})
|
||||||
const packageName = Provider.packageName(resolved.package)
|
const packageName = Provider.packageName(resolved.package)
|
||||||
const key = apiKey(resolved, credential)
|
const key = apiKey(resolved, credential)
|
||||||
@@ -164,21 +166,11 @@ export const fromCatalogModel = (
|
|||||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
Provider.isAISDK(resolved.package) &&
|
|
||||||
packageName === "@ai-sdk/openai-compatible" &&
|
|
||||||
typeof resolved.settings?.baseURL === "string"
|
|
||||||
) {
|
|
||||||
return Effect.succeed(
|
|
||||||
withDefaults(resolved, OpenAICompatibleChat.route)
|
|
||||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
|
||||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const configured = { ...resolved.settings, ...credential?.metadata }
|
const configured = { ...resolved.settings, ...credential?.metadata }
|
||||||
const mapping = Provider.isAISDK(resolved.package)
|
const mapping = Provider.isAISDK(resolved.package)
|
||||||
? AISDKNative.map({
|
? AISDKNative.map({
|
||||||
packageName,
|
packageName,
|
||||||
|
providerID: resolved.providerID,
|
||||||
settings: configured,
|
settings: configured,
|
||||||
modelID: resolved.modelID ?? resolved.id,
|
modelID: resolved.modelID ?? resolved.id,
|
||||||
})
|
})
|
||||||
@@ -257,7 +249,25 @@ export const resolveModel = (
|
|||||||
variant: VariantID | undefined,
|
variant: VariantID | undefined,
|
||||||
credential?: Credential.Value,
|
credential?: Credential.Value,
|
||||||
dependencies?: Dependencies,
|
dependencies?: Dependencies,
|
||||||
) => withVariant(model, variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential, dependencies)))
|
) =>
|
||||||
|
withVariant(model, variant).pipe(
|
||||||
|
Effect.flatMap((model) => {
|
||||||
|
if (!dependencies?.resolveProvider) return fromCatalogModel(model, credential, dependencies)
|
||||||
|
return dependencies
|
||||||
|
.resolveProvider({ model, credential, settings: { ...model.settings, ...credential?.metadata } })
|
||||||
|
.pipe(
|
||||||
|
Effect.flatMap((settings) =>
|
||||||
|
fromCatalogModel(
|
||||||
|
produce(model, (draft) => {
|
||||||
|
draft.settings = settings
|
||||||
|
}),
|
||||||
|
credential,
|
||||||
|
dependencies,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export const supported = (model: Info) => Boolean(model.package)
|
export const supported = (model: Info) => Boolean(model.package)
|
||||||
|
|
||||||
@@ -269,6 +279,7 @@ export const layer = Layer.effect(
|
|||||||
const integrations = yield* Integration.Service
|
const integrations = yield* Integration.Service
|
||||||
const npm = yield* Npm.Service
|
const npm = yield* Npm.Service
|
||||||
const aisdk = yield* AISDK.Service
|
const aisdk = yield* AISDK.Service
|
||||||
|
const hooks = yield* PluginHooks.Service
|
||||||
const load = Effect.fn("ModelResolver.resolveModel")(function* (selected: Info, variant?: VariantID) {
|
const load = Effect.fn("ModelResolver.resolveModel")(function* (selected: Info, variant?: VariantID) {
|
||||||
const provider = yield* catalog.provider.get(selected.providerID)
|
const provider = yield* catalog.provider.get(selected.providerID)
|
||||||
const connection = yield* integrations.connection.active(
|
const connection = yield* integrations.connection.active(
|
||||||
@@ -281,6 +292,8 @@ export const layer = Layer.effect(
|
|||||||
{
|
{
|
||||||
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
|
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
|
||||||
loadAISDK: (model) => aisdk.model(model),
|
loadAISDK: (model) => aisdk.model(model),
|
||||||
|
resolveProvider: (input) =>
|
||||||
|
hooks.trigger("provider", "resolve", input).pipe(Effect.map((event) => event.settings)),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
@@ -318,5 +331,5 @@ export const layer = Layer.effect(
|
|||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer,
|
layer,
|
||||||
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node],
|
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node, PluginHooks.node],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * as PluginHooks from "./hooks"
|
export * as PluginHooks from "./hooks"
|
||||||
|
|
||||||
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
|
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
|
||||||
|
import type { ProviderHooks } from "@opencode-ai/plugin/effect/provider"
|
||||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||||
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
|
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
|
||||||
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
||||||
@@ -10,6 +11,7 @@ import { State } from "../state"
|
|||||||
|
|
||||||
export interface Domains {
|
export interface Domains {
|
||||||
readonly aisdk: AISDKHooks
|
readonly aisdk: AISDKHooks
|
||||||
|
readonly provider: ProviderHooks
|
||||||
readonly session: SessionHooks
|
readonly session: SessionHooks
|
||||||
readonly shell: ShellHooks
|
readonly shell: ShellHooks
|
||||||
readonly tool: ToolHooks
|
readonly tool: ToolHooks
|
||||||
|
|||||||
@@ -273,6 +273,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
|||||||
plugin: {
|
plugin: {
|
||||||
list: () => response(plugin.list()),
|
list: () => response(plugin.list()),
|
||||||
},
|
},
|
||||||
|
provider: {
|
||||||
|
hook: (name, callback) => hooks.register("provider", name, callback),
|
||||||
|
},
|
||||||
reference: {
|
reference: {
|
||||||
list: () => response(reference.list()),
|
list: () => response(reference.list()),
|
||||||
reload: reference.reload,
|
reload: reference.reload,
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
transform: transform(host.catalog),
|
transform: transform(host.catalog),
|
||||||
reload: () => run(host.catalog.reload()),
|
reload: () => run(host.catalog.reload()),
|
||||||
},
|
},
|
||||||
|
provider: {
|
||||||
|
hook: (name, callback) =>
|
||||||
|
register(host.provider.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||||
|
},
|
||||||
command: {
|
command: {
|
||||||
list: (input) => run(host.command.list(input)),
|
list: (input) => run(host.command.list(input)),
|
||||||
transform: transform(host.command),
|
transform: transform(host.command),
|
||||||
|
|||||||
@@ -14,72 +14,34 @@ export const CloudflareWorkersAIPlugin = define({
|
|||||||
if (!item) return
|
if (!item) return
|
||||||
evt.provider.update(item.provider.id, (provider) => {
|
evt.provider.update(item.provider.id, (provider) => {
|
||||||
if (!Provider.isAISDK(provider.package)) return
|
if (!Provider.isAISDK(provider.package)) return
|
||||||
if (typeof provider.settings?.baseURL === "string") return
|
const baseURL = resolveBaseURL(provider.settings ?? {})
|
||||||
const accountId = resolveAccountId(provider.settings ?? {})
|
if (baseURL) provider.settings = { ...provider.settings, baseURL }
|
||||||
if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) }
|
provider.headers = Provider.mergeHeaders(provider.headers, {
|
||||||
|
"User-Agent": `${App.useragent(ctx.app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
yield* ctx.aisdk.hook(
|
yield* ctx.provider.hook(
|
||||||
"sdk",
|
"resolve",
|
||||||
Effect.fn(function* (evt) {
|
Effect.fn(function* (evt) {
|
||||||
if (evt.model.providerID !== providerID) return
|
if (evt.model.providerID !== providerID) return
|
||||||
if (evt.package !== "@ai-sdk/openai-compatible") return
|
const baseURL = resolveBaseURL(evt.settings)
|
||||||
|
if (baseURL) evt.settings.baseURL = baseURL
|
||||||
const accountId = resolveAccountId(evt.options)
|
|
||||||
if (!hasWorkersEndpoint(evt.model) && !accountId) return
|
|
||||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
|
|
||||||
evt.sdk = mod.createOpenAICompatible(
|
|
||||||
sdkOptions(
|
|
||||||
{
|
|
||||||
...evt.options,
|
|
||||||
baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined),
|
|
||||||
},
|
|
||||||
ctx.app,
|
|
||||||
) as any,
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
yield* ctx.aisdk.hook(
|
|
||||||
"language",
|
|
||||||
Effect.fn(function* (evt) {
|
|
||||||
if (evt.model.providerID !== providerID) return
|
|
||||||
evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
function resolveAccountId(options: Record<string, unknown>) {
|
function resolveBaseURL(options: Record<string, unknown>) {
|
||||||
return process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
|
const baseURL = stringOption(options, "baseURL")
|
||||||
|
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
|
||||||
|
if (!accountId) return baseURL
|
||||||
|
if (baseURL) return baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", encodeURIComponent(accountId))
|
||||||
|
return workersEndpoint(accountId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function workersEndpoint(accountId: string) {
|
function workersEndpoint(accountId: string) {
|
||||||
return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`
|
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/ai/v1`
|
||||||
}
|
|
||||||
|
|
||||||
function hasWorkersEndpoint(model: {
|
|
||||||
readonly package?: string
|
|
||||||
readonly settings?: Readonly<Record<string, unknown>>
|
|
||||||
}) {
|
|
||||||
return Provider.isAISDK(model.package) && typeof model.settings?.baseURL === "string"
|
|
||||||
}
|
|
||||||
|
|
||||||
function sdkOptions(options: Record<string, any>, app: App.Info) {
|
|
||||||
return {
|
|
||||||
...options,
|
|
||||||
baseURL: expandAccountId(options.baseURL),
|
|
||||||
apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey,
|
|
||||||
headers: {
|
|
||||||
"User-Agent": `${App.useragent(app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
|
|
||||||
...options.headers,
|
|
||||||
},
|
|
||||||
name: providerID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function expandAccountId(baseURL: unknown) {
|
|
||||||
if (typeof baseURL !== "string") return baseURL
|
|
||||||
return baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", process.env.CLOUDFLARE_ACCOUNT_ID ?? "${CLOUDFLARE_ACCOUNT_ID}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stringOption(options: Record<string, unknown>, key: string) {
|
function stringOption(options: Record<string, unknown>, key: string) {
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { AISDKNative } from "@opencode-ai/core/aisdk-native"
|
import { AISDKNative } from "@opencode-ai/core/aisdk-native"
|
||||||
|
|
||||||
const map = (packageName: string, settings: Readonly<Record<string, unknown>>, modelID = "test-model") =>
|
const map = (
|
||||||
AISDKNative.map({ packageName, settings, modelID })
|
packageName: string,
|
||||||
|
settings: Readonly<Record<string, unknown>>,
|
||||||
|
modelID = "test-model",
|
||||||
|
providerID = "test-provider",
|
||||||
|
) => AISDKNative.map({ packageName, providerID, settings, modelID })
|
||||||
|
|
||||||
describe("AISDKNative", () => {
|
describe("AISDKNative", () => {
|
||||||
test("maps both models.dev Bedrock packages to native providers", () => {
|
test("maps both models.dev Bedrock packages to native providers", () => {
|
||||||
@@ -41,6 +45,52 @@ describe("AISDKNative", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("maps Cloudflare Workers AI to the generic OpenAI-compatible provider", () => {
|
||||||
|
expect(
|
||||||
|
map(
|
||||||
|
"@ai-sdk/openai-compatible",
|
||||||
|
{
|
||||||
|
accountId: "account/id",
|
||||||
|
apiKey: "secret",
|
||||||
|
baseURL: "https://api.cloudflare.com/client/v4/accounts/account%2Fid/ai/v1",
|
||||||
|
headers: { "x-custom": "value" },
|
||||||
|
queryParams: { version: "preview" },
|
||||||
|
reasoningEffort: "high",
|
||||||
|
},
|
||||||
|
"@cf/model",
|
||||||
|
"cloudflare-workers-ai",
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
package: "@opencode-ai/ai/providers/openai-compatible",
|
||||||
|
settings: {
|
||||||
|
apiKey: "secret",
|
||||||
|
baseURL: "https://api.cloudflare.com/client/v4/accounts/account%2Fid/ai/v1",
|
||||||
|
provider: "cloudflare-workers-ai",
|
||||||
|
queryParams: { version: "preview" },
|
||||||
|
providerOptions: { openai: { reasoningEffort: "high" } },
|
||||||
|
},
|
||||||
|
headers: { "x-custom": "value" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("maps generic OpenAI-compatible providers to the native package", () => {
|
||||||
|
expect(
|
||||||
|
map("@ai-sdk/openai-compatible", {
|
||||||
|
apiKey: "secret",
|
||||||
|
baseURL: "https://provider.example/v1",
|
||||||
|
reasoningEffort: "high",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
package: "@opencode-ai/ai/providers/openai-compatible",
|
||||||
|
settings: {
|
||||||
|
apiKey: "secret",
|
||||||
|
baseURL: "https://provider.example/v1",
|
||||||
|
provider: "test-provider",
|
||||||
|
providerOptions: { openai: { reasoningEffort: "high" } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("maps Bedrock provider and request options", () => {
|
test("maps Bedrock provider and request options", () => {
|
||||||
expect(
|
expect(
|
||||||
map(
|
map(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Catalog } from "@opencode-ai/core/catalog"
|
|||||||
import { Generate } from "@opencode-ai/core/generate"
|
import { Generate } from "@opencode-ai/core/generate"
|
||||||
import { Integration } from "@opencode-ai/core/integration"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { ModelResolver } from "@opencode-ai/core/model-resolver"
|
import { ModelResolver } from "@opencode-ai/core/model-resolver"
|
||||||
|
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||||
import { ID, Info, Ref } from "@opencode-ai/core/model"
|
import { ID, Info, Ref } from "@opencode-ai/core/model"
|
||||||
import { Provider } from "@opencode-ai/core/provider"
|
import { Provider } from "@opencode-ai/core/provider"
|
||||||
import { Npm } from "@opencode-ai/util/npm"
|
import { Npm } from "@opencode-ai/util/npm"
|
||||||
@@ -65,9 +66,13 @@ const aisdk = Layer.mock(AISDK.Service, {
|
|||||||
},
|
},
|
||||||
model: () => Effect.succeed(runtime),
|
model: () => Effect.succeed(runtime),
|
||||||
})
|
})
|
||||||
|
const hooks = Layer.mock(PluginHooks.Service, {
|
||||||
|
register: () => Effect.die("unused"),
|
||||||
|
trigger: (_domain, _name, event) => Effect.succeed(event),
|
||||||
|
})
|
||||||
const client = TestLLM.clientLayer.pipe(Layer.provide(TestLLM.layer({ fallback: TestLLM.text("OK", "generate") })))
|
const client = TestLLM.clientLayer.pipe(Layer.provide(TestLLM.layer({ fallback: TestLLM.text("OK", "generate") })))
|
||||||
|
|
||||||
const resolver = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
|
const resolver = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk, hooks)))
|
||||||
const it = testEffect(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, client))))
|
const it = testEffect(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, client))))
|
||||||
const resolverIt = testEffect(resolver)
|
const resolverIt = testEffect(resolver)
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,56 @@ describe("ModelResolver", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("routes Cloudflare Workers AI through the generic OpenAI-compatible provider", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const resolved = yield* ModelResolver.resolveModel(
|
||||||
|
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
|
||||||
|
providerID: Provider.ID.make("cloudflare-workers-ai"),
|
||||||
|
modelID: "@cf/meta/llama-3.1-8b-instruct",
|
||||||
|
settings: {
|
||||||
|
baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
|
||||||
|
queryParams: { version: "preview" },
|
||||||
|
reasoningEffort: "high",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
Credential.Key.make({ type: "key", key: "secret", metadata: { accountId: "account/id" } }),
|
||||||
|
{
|
||||||
|
loadAISDK: () => Effect.die("AI SDK loader should not be called"),
|
||||||
|
resolveProvider: (input) =>
|
||||||
|
Effect.succeed({
|
||||||
|
...input.settings,
|
||||||
|
baseURL: String(input.settings.baseURL).replace(
|
||||||
|
"${CLOUDFLARE_ACCOUNT_ID}",
|
||||||
|
encodeURIComponent(String(input.settings.accountId)),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const headers = yield* resolved.route.auth.apply({
|
||||||
|
request: LLM.request({ model: resolved, prompt: "Hello" }),
|
||||||
|
method: "POST",
|
||||||
|
url: "https://example.com",
|
||||||
|
body: "{}",
|
||||||
|
headers: Headers.empty,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(resolved.route.id).toBe("openai-compatible-chat")
|
||||||
|
expect(String(resolved.provider)).toBe("cloudflare-workers-ai")
|
||||||
|
expect(resolved.route.endpoint.baseURL).toBe("https://api.cloudflare.com/client/v4/accounts/account%2Fid/ai/v1")
|
||||||
|
expect(resolved.route.endpoint.query).toEqual({ version: "preview" })
|
||||||
|
expect(resolved.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high" } })
|
||||||
|
expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true } })
|
||||||
|
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
|
||||||
|
expect(prepared.body).toMatchObject({
|
||||||
|
reasoning_effort: "high",
|
||||||
|
stream_options: { include_usage: true },
|
||||||
|
})
|
||||||
|
expect(prepared.body).not.toHaveProperty("accountId")
|
||||||
|
expect(headers.authorization).toBe("Bearer secret")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
|
it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
|
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
|
||||||
@@ -356,7 +406,7 @@ describe("ModelResolver", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("prefers stored credentials over configured auth", () =>
|
it.effect("keeps key credential metadata out of request bodies", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
|
const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
|
||||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||||
@@ -376,7 +426,7 @@ describe("ModelResolver", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(headers.authorization).toBe("Bearer stored-secret")
|
expect(headers.authorization).toBe("Bearer stored-secret")
|
||||||
expect(resolved.route.defaults.http?.body).toEqual({ tenant: "work" })
|
expect(resolved.route.defaults.http?.body).toEqual({})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
|||||||
aisdk: overrides.aisdk ?? {
|
aisdk: overrides.aisdk ?? {
|
||||||
hook: () => Effect.die("unused aisdk.hook"),
|
hook: () => Effect.die("unused aisdk.hook"),
|
||||||
},
|
},
|
||||||
|
provider: overrides.provider ?? {
|
||||||
|
hook: () => Effect.die("unused provider.hook"),
|
||||||
|
},
|
||||||
catalog: overrides.catalog ?? {
|
catalog: overrides.catalog ?? {
|
||||||
provider: {
|
provider: {
|
||||||
list: () => Effect.die("unused catalog.provider.list"),
|
list: () => Effect.die("unused catalog.provider.list"),
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
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 { Catalog } from "@opencode-ai/core/catalog"
|
||||||
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"
|
||||||
|
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||||
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
|
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
|
||||||
import { Provider } from "@opencode-ai/core/provider"
|
import { Provider } from "@opencode-ai/core/provider"
|
||||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
|
import { ID, Info } from "@opencode-ai/core/model"
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { PluginTestLayer } from "./fixture"
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
@@ -15,9 +15,7 @@ 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
|
yield* CloudflareWorkersAIPlugin.effect(yield* PluginHost.make(plugin))
|
||||||
const host = yield* PluginHost.make(plugin)
|
|
||||||
yield* CloudflareWorkersAIPlugin.effect(host)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function required<T>(value: T | undefined): T {
|
function required<T>(value: T | undefined): T {
|
||||||
@@ -25,243 +23,130 @@ function required<T>(value: T | undefined): T {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
function withEnv<A, E, R>(value: string | undefined, effect: () => Effect.Effect<A, E, R>) {
|
||||||
return Effect.acquireUseRelease(
|
return Effect.acquireUseRelease(
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
const previous = process.env.CLOUDFLARE_ACCOUNT_ID
|
||||||
Object.entries(vars).forEach(([key, value]) => {
|
if (value === undefined) delete process.env.CLOUDFLARE_ACCOUNT_ID
|
||||||
if (value === undefined) delete process.env[key]
|
else process.env.CLOUDFLARE_ACCOUNT_ID = value
|
||||||
else process.env[key] = value
|
|
||||||
})
|
|
||||||
return previous
|
return previous
|
||||||
}),
|
}),
|
||||||
effect,
|
effect,
|
||||||
(previous) =>
|
(previous) =>
|
||||||
Effect.sync(() =>
|
Effect.sync(() => {
|
||||||
Object.entries(previous).forEach(([key, value]) => {
|
if (previous === undefined) delete process.env.CLOUDFLARE_ACCOUNT_ID
|
||||||
if (value === undefined) delete process.env[key]
|
else process.env.CLOUDFLARE_ACCOUNT_ID = previous
|
||||||
else process.env[key] = value
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeSelectorSdk(calls: string[]) {
|
const providerID = Provider.ID.make("cloudflare-workers-ai")
|
||||||
const make = (method: string) => (id: string) => {
|
|
||||||
calls.push(`${method}:${id}`)
|
|
||||||
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
responses: make("responses"),
|
|
||||||
messages: make("messages"),
|
|
||||||
chat: make("chat"),
|
|
||||||
languageModel: make("languageModel"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") {
|
|
||||||
return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel(
|
|
||||||
modelID,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type CloudflareConfig = {
|
|
||||||
url: (input: { path: string; modelId: string }) => string
|
|
||||||
headers: () => Record<string, string> | Promise<Record<string, string>>
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloudflareURL(sdk: unknown, modelID = "@cf/model") {
|
|
||||||
return cloudflareLanguage(sdk, modelID).config.url({ path: "/chat/completions", modelId: modelID })
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloudflareHeaders(sdk: unknown, modelID = "@cf/model") {
|
|
||||||
return cloudflareLanguage(sdk, modelID).config.headers()
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("CloudflareWorkersAIPlugin", () => {
|
describe("CloudflareWorkersAIPlugin", () => {
|
||||||
it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () =>
|
it.effect("resolves the account environment variable into the native endpoint", () =>
|
||||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
|
withEnv("account/id", () =>
|
||||||
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((draft) =>
|
||||||
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
|
draft.provider.update(providerID, (provider) => {
|
||||||
provider.package = Provider.aisdk("test-provider")
|
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* addPlugin()
|
yield* addPlugin()
|
||||||
const provider = required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))
|
|
||||||
const sdk = yield* aisdk.runSDK({
|
expect(required(yield* catalog.provider.get(providerID))).toMatchObject({
|
||||||
model: Model.Info.make({
|
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/account%2Fid/ai/v1" },
|
||||||
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
|
headers: { "User-Agent": expect.stringContaining("cloudflare-workers-ai") },
|
||||||
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",
|
|
||||||
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1" },
|
|
||||||
})
|
|
||||||
expect(sdk.sdk).toBeDefined()
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () =>
|
it.effect("resolves an account ID from provider settings", () =>
|
||||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
|
withEnv(undefined, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* catalog.transform((catalog) =>
|
yield* catalog.transform((draft) =>
|
||||||
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
|
draft.provider.update(providerID, (provider) => {
|
||||||
provider.package = Provider.aisdk("test-provider")
|
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||||
provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" }
|
provider.settings = { accountId: "configured/account" }
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* addPlugin()
|
yield* addPlugin()
|
||||||
expect(required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))).toMatchObject({
|
|
||||||
package: "aisdk:test-provider",
|
|
||||||
settings: { baseURL: "https://proxy.example/v1" },
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("allows a configured baseURL without account ID", () =>
|
expect(required(yield* catalog.provider.get(providerID)).settings?.baseURL).toBe(
|
||||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () =>
|
"https://api.cloudflare.com/client/v4/accounts/configured%2Faccount/ai/v1",
|
||||||
Effect.gen(function* () {
|
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
const aisdk = yield* AISDK.Service
|
|
||||||
yield* addPlugin()
|
|
||||||
const result = 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: "aisdk:@ai-sdk/openai-compatible",
|
|
||||||
settings: { baseURL: "https://proxy.example/v1" },
|
|
||||||
}),
|
|
||||||
package: "@ai-sdk/openai-compatible",
|
|
||||||
options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" },
|
|
||||||
})
|
|
||||||
expect(cloudflareURL(result.sdk)).toBe("https://proxy.example/v1/chat/completions")
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("uses env account ID over configured account ID", () =>
|
|
||||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
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.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" },
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("uses env API key over auth or configured API key and keeps the Cloudflare User-Agent", () =>
|
it.effect("resolves an account ID from credential metadata before native routing", () =>
|
||||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () =>
|
withEnv(undefined, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
const aisdk = yield* AISDK.Service
|
|
||||||
yield* addPlugin()
|
yield* addPlugin()
|
||||||
const result = yield* aisdk.runSDK({
|
const event = yield* (yield* PluginHooks.Service).trigger("provider", "resolve", {
|
||||||
model: Model.Info.make({
|
model: Info.make({
|
||||||
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
|
id: ID.make("model"),
|
||||||
modelID: Model.ID.make("@cf/model"),
|
modelID: ID.make("@cf/model"),
|
||||||
package: "aisdk:@ai-sdk/openai-compatible",
|
providerID,
|
||||||
settings: { baseURL: "https://proxy.example/v1" },
|
name: "Model",
|
||||||
|
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||||
|
settings: {},
|
||||||
|
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||||
|
variants: [],
|
||||||
|
time: { released: 0 },
|
||||||
|
cost: [],
|
||||||
|
status: "active",
|
||||||
|
enabled: true,
|
||||||
|
limit: { context: 128_000, output: 8_192 },
|
||||||
}),
|
}),
|
||||||
package: "@ai-sdk/openai-compatible",
|
credential: Credential.Key.make({ type: "key", key: "secret", metadata: { accountId: "stored/account" } }),
|
||||||
options: {
|
settings: {
|
||||||
name: "cloudflare-workers-ai",
|
accountId: "stored/account",
|
||||||
apiKey: "auth-key",
|
|
||||||
baseURL: "https://proxy.example/v1",
|
|
||||||
headers: { custom: "header" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const headers = yield* Effect.promise(() => Promise.resolve(cloudflareHeaders(result.sdk)))
|
|
||||||
expect(headers.authorization).toBe("Bearer env-key")
|
|
||||||
expect(headers.custom).toBe("header")
|
|
||||||
expect(headers["user-agent"]).toMatch(/^opencode\/.* cloudflare-workers-ai \(.+\) ai-sdk\/openai-compatible\//)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("expands account ID vars in endpoint URLs", () =>
|
|
||||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
const aisdk = yield* AISDK.Service
|
|
||||||
yield* addPlugin()
|
|
||||||
const result = 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: "aisdk:@ai-sdk/openai-compatible",
|
|
||||||
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1" },
|
|
||||||
}),
|
|
||||||
package: "@ai-sdk/openai-compatible",
|
|
||||||
options: {
|
|
||||||
name: "cloudflare-workers-ai",
|
|
||||||
baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
|
baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(cloudflareURL(result.sdk)).toBe(
|
|
||||||
"https://api.cloudflare.com/client/v4/accounts/acct/ai/v1/chat/completions",
|
expect(event.settings.baseURL).toBe(
|
||||||
|
"https://api.cloudflare.com/client/v4/accounts/stored%2Faccount/ai/v1",
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("selects languageModel with the API model ID", () =>
|
it.effect("expands account placeholders and preserves configured endpoints", () =>
|
||||||
Effect.gen(function* () {
|
withEnv("env-account", () =>
|
||||||
const plugin = yield* Plugin.Service
|
Effect.gen(function* () {
|
||||||
const aisdk = yield* AISDK.Service
|
const catalog = yield* Catalog.Service
|
||||||
const calls: string[] = []
|
yield* catalog.transform((draft) =>
|
||||||
yield* addPlugin()
|
draft.provider.update(providerID, (provider) => {
|
||||||
const result = yield* aisdk.runLanguage({
|
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||||
model: Model.Info.make({
|
provider.settings = {
|
||||||
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("alias")),
|
baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
|
||||||
modelID: Model.ID.make("@cf/api-model"),
|
}
|
||||||
package: "aisdk:test-provider",
|
}),
|
||||||
}),
|
)
|
||||||
sdk: fakeSelectorSdk(calls),
|
yield* addPlugin()
|
||||||
options: {},
|
expect(required(yield* catalog.provider.get(providerID)).settings?.baseURL).toBe(
|
||||||
})
|
"https://api.cloudflare.com/client/v4/accounts/env-account/ai/v1",
|
||||||
expect(result.language).toBeDefined()
|
)
|
||||||
expect(calls).toEqual(["languageModel:@cf/api-model"])
|
}),
|
||||||
}),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("does not create an SDK for non OpenAI-compatible packages", () =>
|
it.effect("preserves a custom endpoint when an account ID is configured", () =>
|
||||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
|
withEnv(undefined, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* Plugin.Service
|
const catalog = yield* Catalog.Service
|
||||||
const aisdk = yield* AISDK.Service
|
yield* catalog.transform((draft) =>
|
||||||
yield* addPlugin()
|
draft.provider.update(providerID, (provider) => {
|
||||||
const result = yield* aisdk.runSDK({
|
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||||
model: Model.Info.make({
|
provider.settings = { accountId: "configured-account", baseURL: "https://proxy.example/v1" }
|
||||||
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
|
|
||||||
modelID: Model.ID.make("@cf/model"),
|
|
||||||
package: "aisdk:@ai-sdk/anthropic",
|
|
||||||
settings: { baseURL: "https://proxy.example/v1" },
|
|
||||||
}),
|
}),
|
||||||
package: "@ai-sdk/anthropic",
|
)
|
||||||
options: { name: "cloudflare-workers-ai" },
|
yield* addPlugin()
|
||||||
})
|
expect(required(yield* catalog.provider.get(providerID)).settings?.baseURL).toBe("https://proxy.example/v1")
|
||||||
expect(result.sdk).toBeUndefined()
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { CatalogDomain } from "./catalog.js"
|
|||||||
import type { CommandDomain } from "./command.js"
|
import type { CommandDomain } from "./command.js"
|
||||||
import type { EventDomain } from "./event.js"
|
import type { EventDomain } from "./event.js"
|
||||||
import type { IntegrationDomain } from "./integration.js"
|
import type { IntegrationDomain } from "./integration.js"
|
||||||
|
import type { ProviderDomain } from "./provider.js"
|
||||||
import type { ReferenceDomain } from "./reference.js"
|
import type { ReferenceDomain } from "./reference.js"
|
||||||
import type { SessionDomain } from "./session.js"
|
import type { SessionDomain } from "./session.js"
|
||||||
import type { ShellDomain } from "./shell.js"
|
import type { ShellDomain } from "./shell.js"
|
||||||
@@ -25,6 +26,7 @@ export interface Context {
|
|||||||
readonly event: EventDomain
|
readonly event: EventDomain
|
||||||
readonly integration: IntegrationDomain
|
readonly integration: IntegrationDomain
|
||||||
readonly plugin: PluginApi<unknown>
|
readonly plugin: PluginApi<unknown>
|
||||||
|
readonly provider: ProviderDomain
|
||||||
readonly reference: ReferenceDomain
|
readonly reference: ReferenceDomain
|
||||||
readonly session: SessionDomain
|
readonly session: SessionDomain
|
||||||
readonly shell: ShellDomain
|
readonly shell: ShellDomain
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Credential } from "@opencode-ai/schema/credential"
|
||||||
|
import type { Model } from "@opencode-ai/schema/model"
|
||||||
|
import type { Hooks } from "./registration.js"
|
||||||
|
|
||||||
|
export interface ProviderHooks {
|
||||||
|
resolve: {
|
||||||
|
readonly model: Model.Info
|
||||||
|
readonly credential?: Credential.Value
|
||||||
|
readonly settings: Record<string, unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderDomain {
|
||||||
|
readonly hook: Hooks<ProviderHooks>
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import type { CatalogDomain } from "./catalog.js"
|
|||||||
import type { CommandDomain } from "./command.js"
|
import type { CommandDomain } from "./command.js"
|
||||||
import type { EventDomain } from "./event.js"
|
import type { EventDomain } from "./event.js"
|
||||||
import type { IntegrationDomain } from "./integration.js"
|
import type { IntegrationDomain } from "./integration.js"
|
||||||
|
import type { ProviderDomain } from "./provider.js"
|
||||||
import type { ReferenceDomain } from "./reference.js"
|
import type { ReferenceDomain } from "./reference.js"
|
||||||
import type { SessionDomain } from "./session.js"
|
import type { SessionDomain } from "./session.js"
|
||||||
import type { ShellDomain } from "./shell.js"
|
import type { ShellDomain } from "./shell.js"
|
||||||
@@ -24,6 +25,7 @@ export interface Context {
|
|||||||
readonly event: EventDomain
|
readonly event: EventDomain
|
||||||
readonly integration: IntegrationDomain
|
readonly integration: IntegrationDomain
|
||||||
readonly plugin: PluginApi
|
readonly plugin: PluginApi
|
||||||
|
readonly provider: ProviderDomain
|
||||||
readonly reference: ReferenceDomain
|
readonly reference: ReferenceDomain
|
||||||
readonly session: SessionDomain
|
readonly session: SessionDomain
|
||||||
readonly shell: ShellDomain
|
readonly shell: ShellDomain
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Credential } from "@opencode-ai/schema/credential"
|
||||||
|
import type { Model } from "@opencode-ai/schema/model"
|
||||||
|
import type { Hooks } from "./registration.js"
|
||||||
|
|
||||||
|
export interface ProviderHooks {
|
||||||
|
resolve: {
|
||||||
|
readonly model: Model.Info
|
||||||
|
readonly credential?: Credential.Value
|
||||||
|
readonly settings: Record<string, unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderDomain {
|
||||||
|
readonly hook: Hooks<ProviderHooks>
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user