mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 01:29:44 -04:00
Compare commits
3 Commits
v2
...
ai-native-routing
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c0492cbc5 | |||
| 16f144465b | |||
| 478d0bd533 |
@@ -12,6 +12,7 @@ type GenericModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly provider?: string
|
||||
readonly baseURL: string
|
||||
readonly queryParams?: Readonly<Record<string, string>>
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
@@ -19,6 +20,8 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL: string
|
||||
readonly provider?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
readonly queryParams?: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
@@ -31,11 +34,11 @@ export const routes = [OpenAICompatibleChat.route]
|
||||
|
||||
export const configure = (input: GenericModelOptions) => {
|
||||
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({
|
||||
...rest,
|
||||
provider,
|
||||
endpoint: { baseURL },
|
||||
endpoint: { baseURL, query: queryParams },
|
||||
auth: AuthOptions.bearer(input, []),
|
||||
})
|
||||
return {
|
||||
@@ -75,6 +78,8 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
provider: settings.provider,
|
||||
providerOptions: settings.providerOptions,
|
||||
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
||||
}).model(modelID)
|
||||
|
||||
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 } })
|
||||
})
|
||||
|
||||
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", () => {
|
||||
const selected = model("gpt-5", {
|
||||
apiKey: "fixture",
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface Mapping {
|
||||
|
||||
export interface MapInput {
|
||||
readonly packageName: string | undefined
|
||||
readonly providerID: string
|
||||
readonly settings: Readonly<Record<string, unknown>>
|
||||
readonly modelID: string
|
||||
}
|
||||
@@ -51,6 +52,8 @@ export function map(input: MapInput): Mapping | undefined {
|
||||
...mapGoogleOptions(input.settings),
|
||||
},
|
||||
}
|
||||
case "@ai-sdk/openai-compatible":
|
||||
return mapOpenAICompatible(input, baseSettings)
|
||||
case "@openrouter/ai-sdk-provider":
|
||||
return mapOpenRouter(input.settings, baseSettings)
|
||||
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 {
|
||||
|
||||
@@ -5,8 +5,6 @@ import { LanguageModel } from "@opencode-ai/ai"
|
||||
// ast-grep-ignore: no-star-import
|
||||
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
|
||||
// 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 { Auth, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
@@ -18,6 +16,7 @@ import { Credential } from "./credential"
|
||||
import { Integration } from "./integration"
|
||||
import { Capabilities, ID, Info, Ref, VariantID } from "./model"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { PluginHooks } from "./plugin/hooks"
|
||||
import { Provider } from "./provider"
|
||||
|
||||
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
|
||||
@@ -135,6 +134,11 @@ export const withVariant = (
|
||||
export interface Dependencies {
|
||||
readonly loadPackage?: (specifier: string) => Effect.Effect<Provider.ProviderPackage, Provider.LoadError>
|
||||
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 = (
|
||||
@@ -144,8 +148,6 @@ export const fromCatalogModel = (
|
||||
): Effect.Effect<LanguageModel, UnsupportedPackageError> => {
|
||||
const resolved = produce(model, (draft) => {
|
||||
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 key = apiKey(resolved, credential)
|
||||
@@ -164,21 +166,11 @@ export const fromCatalogModel = (
|
||||
.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 mapping = Provider.isAISDK(resolved.package)
|
||||
? AISDKNative.map({
|
||||
packageName,
|
||||
providerID: resolved.providerID,
|
||||
settings: configured,
|
||||
modelID: resolved.modelID ?? resolved.id,
|
||||
})
|
||||
@@ -257,7 +249,25 @@ export const resolveModel = (
|
||||
variant: VariantID | undefined,
|
||||
credential?: Credential.Value,
|
||||
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)
|
||||
|
||||
@@ -269,6 +279,7 @@ export const layer = Layer.effect(
|
||||
const integrations = yield* Integration.Service
|
||||
const npm = yield* Npm.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const load = Effect.fn("ModelResolver.resolveModel")(function* (selected: Info, variant?: VariantID) {
|
||||
const provider = yield* catalog.provider.get(selected.providerID)
|
||||
const connection = yield* integrations.connection.active(
|
||||
@@ -281,6 +292,8 @@ export const layer = Layer.effect(
|
||||
{
|
||||
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
|
||||
loadAISDK: (model) => aisdk.model(model),
|
||||
resolveProvider: (input) =>
|
||||
hooks.trigger("provider", "resolve", input).pipe(Effect.map((event) => event.settings)),
|
||||
},
|
||||
)
|
||||
return {
|
||||
@@ -318,5 +331,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
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"
|
||||
|
||||
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 { ShellHooks } from "@opencode-ai/plugin/effect/shell"
|
||||
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
||||
@@ -10,6 +11,7 @@ import { State } from "../state"
|
||||
|
||||
export interface Domains {
|
||||
readonly aisdk: AISDKHooks
|
||||
readonly provider: ProviderHooks
|
||||
readonly session: SessionHooks
|
||||
readonly shell: ShellHooks
|
||||
readonly tool: ToolHooks
|
||||
|
||||
@@ -273,6 +273,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
plugin: {
|
||||
list: () => response(plugin.list()),
|
||||
},
|
||||
provider: {
|
||||
hook: (name, callback) => hooks.register("provider", name, callback),
|
||||
},
|
||||
reference: {
|
||||
list: () => response(reference.list()),
|
||||
reload: reference.reload,
|
||||
|
||||
@@ -84,6 +84,10 @@ export function fromPromise(plugin: Plugin) {
|
||||
transform: transform(host.catalog),
|
||||
reload: () => run(host.catalog.reload()),
|
||||
},
|
||||
provider: {
|
||||
hook: (name, callback) =>
|
||||
register(host.provider.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
},
|
||||
command: {
|
||||
list: (input) => run(host.command.list(input)),
|
||||
transform: transform(host.command),
|
||||
|
||||
@@ -14,72 +14,34 @@ export const CloudflareWorkersAIPlugin = define({
|
||||
if (!item) return
|
||||
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) }
|
||||
const baseURL = resolveBaseURL(provider.settings ?? {})
|
||||
if (baseURL) provider.settings = { ...provider.settings, baseURL }
|
||||
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(
|
||||
"sdk",
|
||||
yield* ctx.provider.hook(
|
||||
"resolve",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== providerID) return
|
||||
if (evt.package !== "@ai-sdk/openai-compatible") return
|
||||
|
||||
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)
|
||||
const baseURL = resolveBaseURL(evt.settings)
|
||||
if (baseURL) evt.settings.baseURL = baseURL
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function resolveAccountId(options: Record<string, unknown>) {
|
||||
return process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
|
||||
function resolveBaseURL(options: Record<string, unknown>) {
|
||||
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) {
|
||||
return `https://api.cloudflare.com/client/v4/accounts/${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}")
|
||||
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/ai/v1`
|
||||
}
|
||||
|
||||
function stringOption(options: Record<string, unknown>, key: string) {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { AISDKNative } from "@opencode-ai/core/aisdk-native"
|
||||
|
||||
const map = (packageName: string, settings: Readonly<Record<string, unknown>>, modelID = "test-model") =>
|
||||
AISDKNative.map({ packageName, settings, modelID })
|
||||
const map = (
|
||||
packageName: string,
|
||||
settings: Readonly<Record<string, unknown>>,
|
||||
modelID = "test-model",
|
||||
providerID = "test-provider",
|
||||
) => AISDKNative.map({ packageName, providerID, settings, modelID })
|
||||
|
||||
describe("AISDKNative", () => {
|
||||
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", () => {
|
||||
expect(
|
||||
map(
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Generate } from "@opencode-ai/core/generate"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
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 { Provider } from "@opencode-ai/core/provider"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
@@ -65,9 +66,13 @@ const aisdk = Layer.mock(AISDK.Service, {
|
||||
},
|
||||
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 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 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", () =>
|
||||
Effect.gen(function* () {
|
||||
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* () {
|
||||
const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
@@ -376,7 +426,7 @@ describe("ModelResolver", () => {
|
||||
})
|
||||
|
||||
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 ?? {
|
||||
hook: () => Effect.die("unused aisdk.hook"),
|
||||
},
|
||||
provider: overrides.provider ?? {
|
||||
hook: () => Effect.die("unused provider.hook"),
|
||||
},
|
||||
catalog: overrides.catalog ?? {
|
||||
provider: {
|
||||
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 { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
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 { 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 { PluginTestLayer } from "./fixture"
|
||||
|
||||
@@ -15,9 +15,7 @@ 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)
|
||||
yield* CloudflareWorkersAIPlugin.effect(yield* PluginHost.make(plugin))
|
||||
})
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
@@ -25,243 +23,130 @@ function required<T>(value: T | undefined): T {
|
||||
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(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
const previous = process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
if (value === undefined) delete process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
else process.env.CLOUDFLARE_ACCOUNT_ID = value
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() =>
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}),
|
||||
),
|
||||
Effect.sync(() => {
|
||||
if (previous === undefined) delete process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
else process.env.CLOUDFLARE_ACCOUNT_ID = previous
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
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()
|
||||
}
|
||||
const providerID = Provider.ID.make("cloudflare-workers-ai")
|
||||
|
||||
describe("CloudflareWorkersAIPlugin", () => {
|
||||
it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
|
||||
it.effect("resolves the account environment variable into the native endpoint", () =>
|
||||
withEnv("account/id", () =>
|
||||
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")
|
||||
yield* catalog.transform((draft) =>
|
||||
draft.provider.update(providerID, (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
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(required(yield* catalog.provider.get(providerID))).toMatchObject({
|
||||
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/account%2Fid/ai/v1" },
|
||||
headers: { "User-Agent": expect.stringContaining("cloudflare-workers-ai") },
|
||||
})
|
||||
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", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
|
||||
it.effect("resolves an account ID from provider settings", () =>
|
||||
withEnv(undefined, () =>
|
||||
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, baseURL: "https://proxy.example/v1" }
|
||||
yield* catalog.transform((draft) =>
|
||||
draft.provider.update(providerID, (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { accountId: "configured/account" }
|
||||
}),
|
||||
)
|
||||
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", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, 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://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" }
|
||||
}),
|
||||
expect(required(yield* catalog.provider.get(providerID)).settings?.baseURL).toBe(
|
||||
"https://api.cloudflare.com/client/v4/accounts/configured%2Faccount/ai/v1",
|
||||
)
|
||||
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", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () =>
|
||||
it.effect("resolves an account ID from credential metadata before native routing", () =>
|
||||
withEnv(undefined, () =>
|
||||
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" },
|
||||
const event = yield* (yield* PluginHooks.Service).trigger("provider", "resolve", {
|
||||
model: Info.make({
|
||||
id: ID.make("model"),
|
||||
modelID: ID.make("@cf/model"),
|
||||
providerID,
|
||||
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",
|
||||
options: {
|
||||
name: "cloudflare-workers-ai",
|
||||
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",
|
||||
credential: Credential.Key.make({ type: "key", key: "secret", metadata: { accountId: "stored/account" } }),
|
||||
settings: {
|
||||
accountId: "stored/account",
|
||||
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", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("alias")),
|
||||
modelID: Model.ID.make("@cf/api-model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
})
|
||||
expect(result.language).toBeDefined()
|
||||
expect(calls).toEqual(["languageModel:@cf/api-model"])
|
||||
}),
|
||||
it.effect("expands account placeholders and preserves configured endpoints", () =>
|
||||
withEnv("env-account", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
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",
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(providerID)).settings?.baseURL).toBe(
|
||||
"https://api.cloudflare.com/client/v4/accounts/env-account/ai/v1",
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("does not create an SDK for non OpenAI-compatible packages", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
|
||||
it.effect("preserves a custom endpoint when an account ID is configured", () =>
|
||||
withEnv(undefined, () =>
|
||||
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/anthropic",
|
||||
settings: { baseURL: "https://proxy.example/v1" },
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((draft) =>
|
||||
draft.provider.update(providerID, (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { accountId: "configured-account", baseURL: "https://proxy.example/v1" }
|
||||
}),
|
||||
package: "@ai-sdk/anthropic",
|
||||
options: { name: "cloudflare-workers-ai" },
|
||||
})
|
||||
expect(result.sdk).toBeUndefined()
|
||||
)
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(providerID)).settings?.baseURL).toBe("https://proxy.example/v1")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { CatalogDomain } from "./catalog.js"
|
||||
import type { CommandDomain } from "./command.js"
|
||||
import type { EventDomain } from "./event.js"
|
||||
import type { IntegrationDomain } from "./integration.js"
|
||||
import type { ProviderDomain } from "./provider.js"
|
||||
import type { ReferenceDomain } from "./reference.js"
|
||||
import type { SessionDomain } from "./session.js"
|
||||
import type { ShellDomain } from "./shell.js"
|
||||
@@ -25,6 +26,7 @@ export interface Context {
|
||||
readonly event: EventDomain
|
||||
readonly integration: IntegrationDomain
|
||||
readonly plugin: PluginApi<unknown>
|
||||
readonly provider: ProviderDomain
|
||||
readonly reference: ReferenceDomain
|
||||
readonly session: SessionDomain
|
||||
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 { EventDomain } from "./event.js"
|
||||
import type { IntegrationDomain } from "./integration.js"
|
||||
import type { ProviderDomain } from "./provider.js"
|
||||
import type { ReferenceDomain } from "./reference.js"
|
||||
import type { SessionDomain } from "./session.js"
|
||||
import type { ShellDomain } from "./shell.js"
|
||||
@@ -24,6 +25,7 @@ export interface Context {
|
||||
readonly event: EventDomain
|
||||
readonly integration: IntegrationDomain
|
||||
readonly plugin: PluginApi
|
||||
readonly provider: ProviderDomain
|
||||
readonly reference: ReferenceDomain
|
||||
readonly session: SessionDomain
|
||||
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