refactor(provider): stop custom loaders using facades (#20776)
Co-authored-by: luanweslley77 <213105503+luanweslley77@users.noreply.github.com>pull/20752/head^2
parent
650d0dbe54
commit
59ca4543d8
|
|
@ -152,7 +152,7 @@ export namespace Provider {
|
||||||
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
|
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
|
||||||
type CustomVarsLoader = (options: Record<string, any>) => Record<string, string>
|
type CustomVarsLoader = (options: Record<string, any>) => Record<string, string>
|
||||||
type CustomDiscoverModels = () => Promise<Record<string, Model>>
|
type CustomDiscoverModels = () => Promise<Record<string, Model>>
|
||||||
type CustomLoader = (provider: Info) => Promise<{
|
type CustomLoader = (provider: Info) => Effect.Effect<{
|
||||||
autoload: boolean
|
autoload: boolean
|
||||||
getModel?: CustomModelLoader
|
getModel?: CustomModelLoader
|
||||||
vars?: CustomVarsLoader
|
vars?: CustomVarsLoader
|
||||||
|
|
@ -160,32 +160,38 @@ export namespace Provider {
|
||||||
discoverModels?: CustomDiscoverModels
|
discoverModels?: CustomDiscoverModels
|
||||||
}>
|
}>
|
||||||
|
|
||||||
|
type CustomDep = {
|
||||||
|
auth: (id: string) => Effect.Effect<Auth.Info | undefined>
|
||||||
|
config: () => Effect.Effect<Config.Info>
|
||||||
|
}
|
||||||
|
|
||||||
function useLanguageModel(sdk: any) {
|
function useLanguageModel(sdk: any) {
|
||||||
return sdk.responses === undefined && sdk.chat === undefined
|
return sdk.responses === undefined && sdk.chat === undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const CUSTOM_LOADERS: Record<string, CustomLoader> = {
|
function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||||
async anthropic() {
|
|
||||||
return {
|
return {
|
||||||
|
anthropic: () =>
|
||||||
|
Effect.succeed({
|
||||||
autoload: false,
|
autoload: false,
|
||||||
options: {
|
options: {
|
||||||
headers: {
|
headers: {
|
||||||
"anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
|
"anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}),
|
||||||
},
|
opencode: Effect.fnUntraced(function* (input: Info) {
|
||||||
async opencode(input) {
|
|
||||||
const hasKey = await (async () => {
|
|
||||||
const env = Env.all()
|
const env = Env.all()
|
||||||
|
const hasKey = iife(() => {
|
||||||
if (input.env.some((item) => env[item])) return true
|
if (input.env.some((item) => env[item])) return true
|
||||||
if (await Auth.get(input.id)) return true
|
|
||||||
const config = await Config.get()
|
|
||||||
if (config.provider?.["opencode"]?.options?.apiKey) return true
|
|
||||||
return false
|
return false
|
||||||
})()
|
})
|
||||||
|
const ok =
|
||||||
|
hasKey ||
|
||||||
|
Boolean(yield* dep.auth(input.id)) ||
|
||||||
|
Boolean((yield* dep.config()).provider?.["opencode"]?.options?.apiKey)
|
||||||
|
|
||||||
if (!hasKey) {
|
if (!ok) {
|
||||||
for (const [key, value] of Object.entries(input.models)) {
|
for (const [key, value] of Object.entries(input.models)) {
|
||||||
if (value.cost.input === 0) continue
|
if (value.cost.input === 0) continue
|
||||||
delete input.models[key]
|
delete input.models[key]
|
||||||
|
|
@ -194,45 +200,42 @@ export namespace Provider {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
autoload: Object.keys(input.models).length > 0,
|
autoload: Object.keys(input.models).length > 0,
|
||||||
options: hasKey ? {} : { apiKey: "public" },
|
options: ok ? {} : { apiKey: "public" },
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
openai: async () => {
|
openai: () =>
|
||||||
return {
|
Effect.succeed({
|
||||||
autoload: false,
|
autoload: false,
|
||||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
||||||
return sdk.responses(modelID)
|
return sdk.responses(modelID)
|
||||||
},
|
},
|
||||||
options: {},
|
options: {},
|
||||||
}
|
}),
|
||||||
},
|
xai: () =>
|
||||||
xai: async () => {
|
Effect.succeed({
|
||||||
return {
|
|
||||||
autoload: false,
|
autoload: false,
|
||||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
||||||
return sdk.responses(modelID)
|
return sdk.responses(modelID)
|
||||||
},
|
},
|
||||||
options: {},
|
options: {},
|
||||||
}
|
}),
|
||||||
},
|
"github-copilot": () =>
|
||||||
"github-copilot": async () => {
|
Effect.succeed({
|
||||||
return {
|
|
||||||
autoload: false,
|
autoload: false,
|
||||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
||||||
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
||||||
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
|
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
|
||||||
},
|
},
|
||||||
options: {},
|
options: {},
|
||||||
}
|
}),
|
||||||
},
|
azure: (provider) => {
|
||||||
azure: async (provider) => {
|
|
||||||
const resource = iife(() => {
|
const resource = iife(() => {
|
||||||
const name = provider.options?.resourceName
|
const name = provider.options?.resourceName
|
||||||
if (typeof name === "string" && name.trim() !== "") return name
|
if (typeof name === "string" && name.trim() !== "") return name
|
||||||
return Env.get("AZURE_RESOURCE_NAME")
|
return Env.get("AZURE_RESOURCE_NAME")
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return Effect.succeed({
|
||||||
autoload: false,
|
autoload: false,
|
||||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||||
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
||||||
|
|
@ -248,11 +251,11 @@ export namespace Provider {
|
||||||
...(resource && { AZURE_RESOURCE_NAME: resource }),
|
...(resource && { AZURE_RESOURCE_NAME: resource }),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
},
|
},
|
||||||
"azure-cognitive-services": async () => {
|
"azure-cognitive-services": () => {
|
||||||
const resourceName = Env.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
|
const resourceName = Env.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
|
||||||
return {
|
return Effect.succeed({
|
||||||
autoload: false,
|
autoload: false,
|
||||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||||
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
||||||
|
|
@ -265,13 +268,11 @@ export namespace Provider {
|
||||||
options: {
|
options: {
|
||||||
baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
|
baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
},
|
},
|
||||||
"amazon-bedrock": async () => {
|
"amazon-bedrock": Effect.fnUntraced(function* () {
|
||||||
const config = await Config.get()
|
const providerConfig = (yield* dep.config()).provider?.["amazon-bedrock"]
|
||||||
const providerConfig = config.provider?.["amazon-bedrock"]
|
const auth = yield* dep.auth("amazon-bedrock")
|
||||||
|
|
||||||
const auth = await Auth.get("amazon-bedrock")
|
|
||||||
|
|
||||||
// Region precedence: 1) config file, 2) env var, 3) default
|
// Region precedence: 1) config file, 2) env var, 3) default
|
||||||
const configRegion = providerConfig?.options?.region
|
const configRegion = providerConfig?.options?.region
|
||||||
|
|
@ -414,9 +415,9 @@ export namespace Provider {
|
||||||
return sdk.languageModel(modelID)
|
return sdk.languageModel(modelID)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
openrouter: async () => {
|
openrouter: () =>
|
||||||
return {
|
Effect.succeed({
|
||||||
autoload: false,
|
autoload: false,
|
||||||
options: {
|
options: {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -424,10 +425,9 @@ export namespace Provider {
|
||||||
"X-Title": "opencode",
|
"X-Title": "opencode",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}),
|
||||||
},
|
vercel: () =>
|
||||||
vercel: async () => {
|
Effect.succeed({
|
||||||
return {
|
|
||||||
autoload: false,
|
autoload: false,
|
||||||
options: {
|
options: {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -435,9 +435,8 @@ export namespace Provider {
|
||||||
"x-title": "opencode",
|
"x-title": "opencode",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}),
|
||||||
},
|
"google-vertex": (provider) => {
|
||||||
"google-vertex": async (provider) => {
|
|
||||||
const project =
|
const project =
|
||||||
provider.options?.project ??
|
provider.options?.project ??
|
||||||
Env.get("GOOGLE_CLOUD_PROJECT") ??
|
Env.get("GOOGLE_CLOUD_PROJECT") ??
|
||||||
|
|
@ -453,11 +452,12 @@ export namespace Provider {
|
||||||
)
|
)
|
||||||
|
|
||||||
const autoload = Boolean(project)
|
const autoload = Boolean(project)
|
||||||
if (!autoload) return { autoload: false }
|
if (!autoload) return Effect.succeed({ autoload: false })
|
||||||
return {
|
return Effect.succeed({
|
||||||
autoload: true,
|
autoload: true,
|
||||||
vars(_options: Record<string, any>) {
|
vars(_options: Record<string, any>) {
|
||||||
const endpoint = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`
|
const endpoint =
|
||||||
|
location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`
|
||||||
return {
|
return {
|
||||||
...(project && { GOOGLE_VERTEX_PROJECT: project }),
|
...(project && { GOOGLE_VERTEX_PROJECT: project }),
|
||||||
GOOGLE_VERTEX_LOCATION: location,
|
GOOGLE_VERTEX_LOCATION: location,
|
||||||
|
|
@ -482,14 +482,14 @@ export namespace Provider {
|
||||||
const id = String(modelID).trim()
|
const id = String(modelID).trim()
|
||||||
return sdk.languageModel(id)
|
return sdk.languageModel(id)
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
},
|
},
|
||||||
"google-vertex-anthropic": async () => {
|
"google-vertex-anthropic": () => {
|
||||||
const project = Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
|
const project = Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
|
||||||
const location = Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "global"
|
const location = Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "global"
|
||||||
const autoload = Boolean(project)
|
const autoload = Boolean(project)
|
||||||
if (!autoload) return { autoload: false }
|
if (!autoload) return Effect.succeed({ autoload: false })
|
||||||
return {
|
return Effect.succeed({
|
||||||
autoload: true,
|
autoload: true,
|
||||||
options: {
|
options: {
|
||||||
project,
|
project,
|
||||||
|
|
@ -499,10 +499,10 @@ export namespace Provider {
|
||||||
const id = String(modelID).trim()
|
const id = String(modelID).trim()
|
||||||
return sdk.languageModel(id)
|
return sdk.languageModel(id)
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
},
|
},
|
||||||
"sap-ai-core": async () => {
|
"sap-ai-core": Effect.fnUntraced(function* () {
|
||||||
const auth = await Auth.get("sap-ai-core")
|
const auth = yield* dep.auth("sap-ai-core")
|
||||||
// TODO: Using process.env directly because Env.set only updates a shallow copy (not process.env),
|
// TODO: Using process.env directly because Env.set only updates a shallow copy (not process.env),
|
||||||
// until the scope of the Env API is clarified (test only or runtime?)
|
// until the scope of the Env API is clarified (test only or runtime?)
|
||||||
const envServiceKey = iife(() => {
|
const envServiceKey = iife(() => {
|
||||||
|
|
@ -524,9 +524,9 @@ export namespace Provider {
|
||||||
return sdk(modelID)
|
return sdk(modelID)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
zenmux: async () => {
|
zenmux: () =>
|
||||||
return {
|
Effect.succeed({
|
||||||
autoload: false,
|
autoload: false,
|
||||||
options: {
|
options: {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -534,20 +534,18 @@ export namespace Provider {
|
||||||
"X-Title": "opencode",
|
"X-Title": "opencode",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}),
|
||||||
},
|
gitlab: Effect.fnUntraced(function* (input: Info) {
|
||||||
gitlab: async (input) => {
|
|
||||||
const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com"
|
const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com"
|
||||||
|
|
||||||
const auth = await Auth.get(input.id)
|
const auth = yield* dep.auth(input.id)
|
||||||
const apiKey = await (async () => {
|
const apiKey = yield* Effect.sync(() => {
|
||||||
if (auth?.type === "oauth") return auth.access
|
if (auth?.type === "oauth") return auth.access
|
||||||
if (auth?.type === "api") return auth.key
|
if (auth?.type === "api") return auth.key
|
||||||
return Env.get("GITLAB_TOKEN")
|
return Env.get("GITLAB_TOKEN")
|
||||||
})()
|
})
|
||||||
|
|
||||||
const config = await Config.get()
|
const providerConfig = (yield* dep.config()).provider?.["gitlab"]
|
||||||
const providerConfig = config.provider?.["gitlab"]
|
|
||||||
|
|
||||||
const aiGatewayHeaders = {
|
const aiGatewayHeaders = {
|
||||||
"User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
|
"User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||||
|
|
@ -672,15 +670,15 @@ export namespace Provider {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
"cloudflare-workers-ai": async (input) => {
|
"cloudflare-workers-ai": Effect.fnUntraced(function* (input: Info) {
|
||||||
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
|
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
|
||||||
if (!accountId) return { autoload: false }
|
if (!accountId) return { autoload: false }
|
||||||
|
|
||||||
const apiKey = await iife(async () => {
|
const apiKey = yield* Effect.gen(function* () {
|
||||||
const envToken = Env.get("CLOUDFLARE_API_KEY")
|
const envToken = Env.get("CLOUDFLARE_API_KEY")
|
||||||
if (envToken) return envToken
|
if (envToken) return envToken
|
||||||
const auth = await Auth.get(input.id)
|
const auth = yield* dep.auth(input.id)
|
||||||
if (auth?.type === "api") return auth.key
|
if (auth?.type === "api") return auth.key
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
@ -702,21 +700,21 @@ export namespace Provider {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
"cloudflare-ai-gateway": async (input) => {
|
"cloudflare-ai-gateway": Effect.fnUntraced(function* (input: Info) {
|
||||||
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
|
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
|
||||||
const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")
|
const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")
|
||||||
|
|
||||||
if (!accountId || !gateway) return { autoload: false }
|
if (!accountId || !gateway) return { autoload: false }
|
||||||
|
|
||||||
// Get API token from env or auth - required for authenticated gateways
|
// Get API token from env or auth - required for authenticated gateways
|
||||||
const apiToken = await (async () => {
|
const apiToken = yield* Effect.gen(function* () {
|
||||||
const envToken = Env.get("CLOUDFLARE_API_TOKEN") || Env.get("CF_AIG_TOKEN")
|
const envToken = Env.get("CLOUDFLARE_API_TOKEN") || Env.get("CF_AIG_TOKEN")
|
||||||
if (envToken) return envToken
|
if (envToken) return envToken
|
||||||
const auth = await Auth.get(input.id)
|
const auth = yield* dep.auth(input.id)
|
||||||
if (auth?.type === "api") return auth.key
|
if (auth?.type === "api") return auth.key
|
||||||
return undefined
|
return undefined
|
||||||
})()
|
})
|
||||||
|
|
||||||
if (!apiToken) {
|
if (!apiToken) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -726,8 +724,8 @@ export namespace Provider {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use official ai-gateway-provider package (v2.x for AI SDK v5 compatibility)
|
// Use official ai-gateway-provider package (v2.x for AI SDK v5 compatibility)
|
||||||
const { createAiGateway } = await import("ai-gateway-provider")
|
const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider"))
|
||||||
const { createUnified } = await import("ai-gateway-provider/providers/unified")
|
const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified"))
|
||||||
|
|
||||||
const metadata = iife(() => {
|
const metadata = iife(() => {
|
||||||
if (input.options?.metadata) return input.options.metadata
|
if (input.options?.metadata) return input.options.metadata
|
||||||
|
|
@ -764,19 +762,18 @@ export namespace Provider {
|
||||||
},
|
},
|
||||||
options: {},
|
options: {},
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
cerebras: async () => {
|
cerebras: () =>
|
||||||
return {
|
Effect.succeed({
|
||||||
autoload: false,
|
autoload: false,
|
||||||
options: {
|
options: {
|
||||||
headers: {
|
headers: {
|
||||||
"X-Cerebras-3rd-Party-Integration": "opencode",
|
"X-Cerebras-3rd-Party-Integration": "opencode",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}),
|
||||||
},
|
kilo: () =>
|
||||||
kilo: async () => {
|
Effect.succeed({
|
||||||
return {
|
|
||||||
autoload: false,
|
autoload: false,
|
||||||
options: {
|
options: {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -784,8 +781,8 @@ export namespace Provider {
|
||||||
"X-Title": "opencode",
|
"X-Title": "opencode",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Model = z
|
export const Model = z
|
||||||
|
|
@ -989,15 +986,6 @@ export namespace Provider {
|
||||||
const modelsDev = yield* Effect.promise(() => ModelsDev.get())
|
const modelsDev = yield* Effect.promise(() => ModelsDev.get())
|
||||||
const database = mapValues(modelsDev, fromModelsDevProvider)
|
const database = mapValues(modelsDev, fromModelsDevProvider)
|
||||||
|
|
||||||
const disabled = new Set(cfg.disabled_providers ?? [])
|
|
||||||
const enabled = cfg.enabled_providers ? new Set(cfg.enabled_providers) : null
|
|
||||||
|
|
||||||
function isProviderAllowed(providerID: ProviderID): boolean {
|
|
||||||
if (enabled && !enabled.has(providerID)) return false
|
|
||||||
if (disabled.has(providerID)) return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const providers: Record<ProviderID, Info> = {} as Record<ProviderID, Info>
|
const providers: Record<ProviderID, Info> = {} as Record<ProviderID, Info>
|
||||||
const languages = new Map<string, LanguageModelV3>()
|
const languages = new Map<string, LanguageModelV3>()
|
||||||
const modelLoaders: {
|
const modelLoaders: {
|
||||||
|
|
@ -1010,11 +998,13 @@ export namespace Provider {
|
||||||
const discoveryLoaders: {
|
const discoveryLoaders: {
|
||||||
[providerID: string]: CustomDiscoverModels
|
[providerID: string]: CustomDiscoverModels
|
||||||
} = {}
|
} = {}
|
||||||
|
const dep = {
|
||||||
|
auth: (id: string) => auth.get(id).pipe(Effect.orDie),
|
||||||
|
config: () => config.get(),
|
||||||
|
}
|
||||||
|
|
||||||
log.info("init")
|
log.info("init")
|
||||||
|
|
||||||
const configProviders = Object.entries(cfg.provider ?? {})
|
|
||||||
|
|
||||||
function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
|
function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
|
||||||
const existing = providers[providerID]
|
const existing = providers[providerID]
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|
@ -1028,6 +1018,20 @@ export namespace Provider {
|
||||||
providers[providerID] = mergeDeep(match, provider)
|
providers[providerID] = mergeDeep(match, provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// load plugins first so config() hook runs before reading cfg.provider
|
||||||
|
const plugins = yield* plugin.list()
|
||||||
|
|
||||||
|
// now read config providers - includes any modifications from plugin config() hook
|
||||||
|
const configProviders = Object.entries(cfg.provider ?? {})
|
||||||
|
const disabled = new Set(cfg.disabled_providers ?? [])
|
||||||
|
const enabled = cfg.enabled_providers ? new Set(cfg.enabled_providers) : null
|
||||||
|
|
||||||
|
function isProviderAllowed(providerID: ProviderID): boolean {
|
||||||
|
if (enabled && !enabled.has(providerID)) return false
|
||||||
|
if (disabled.has(providerID)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// extend database from config
|
// extend database from config
|
||||||
for (const [providerID, provider] of configProviders) {
|
for (const [providerID, provider] of configProviders) {
|
||||||
const existing = database[providerID]
|
const existing = database[providerID]
|
||||||
|
|
@ -1144,25 +1148,28 @@ export namespace Provider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugins = yield* plugin.list()
|
// plugin auth loader - database now has entries for config providers
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
if (!plugin.auth) continue
|
if (!plugin.auth) continue
|
||||||
const providerID = ProviderID.make(plugin.auth.provider)
|
const providerID = ProviderID.make(plugin.auth.provider)
|
||||||
if (disabled.has(providerID)) continue
|
if (disabled.has(providerID)) continue
|
||||||
|
|
||||||
const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
|
const stored = yield* auth.get(providerID).pipe(Effect.orDie)
|
||||||
if (!pluginAuth) continue
|
if (!stored) continue
|
||||||
if (!plugin.auth.loader) continue
|
if (!plugin.auth.loader) continue
|
||||||
|
|
||||||
const options = yield* Effect.promise(() =>
|
const options = yield* Effect.promise(() =>
|
||||||
plugin.auth!.loader!(() => Auth.get(providerID) as any, database[plugin.auth!.provider]),
|
plugin.auth!.loader!(
|
||||||
|
() => Effect.runPromise(auth.get(providerID).pipe(Effect.orDie)) as any,
|
||||||
|
database[plugin.auth!.provider],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
const opts = options ?? {}
|
const opts = options ?? {}
|
||||||
const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
|
const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
|
||||||
mergeProvider(providerID, patch)
|
mergeProvider(providerID, patch)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [id, fn] of Object.entries(CUSTOM_LOADERS)) {
|
for (const [id, fn] of Object.entries(custom(dep))) {
|
||||||
const providerID = ProviderID.make(id)
|
const providerID = ProviderID.make(id)
|
||||||
if (disabled.has(providerID)) continue
|
if (disabled.has(providerID)) continue
|
||||||
const data = database[providerID]
|
const data = database[providerID]
|
||||||
|
|
@ -1170,7 +1177,7 @@ export namespace Provider {
|
||||||
log.error("Provider does not exist in model list " + providerID)
|
log.error("Provider does not exist in model list " + providerID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const result = yield* Effect.promise(() => fn(data))
|
const result = yield* fn(data)
|
||||||
if (result && (result.autoload || providers[providerID])) {
|
if (result && (result.autoload || providers[providerID])) {
|
||||||
if (result.getModel) modelLoaders[providerID] = result.getModel
|
if (result.getModel) modelLoaders[providerID] = result.getModel
|
||||||
if (result.vars) varsLoaders[providerID] = result.vars
|
if (result.vars) varsLoaders[providerID] = result.vars
|
||||||
|
|
@ -1183,7 +1190,7 @@ export namespace Provider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// load config
|
// load config - re-apply with updated data
|
||||||
for (const [id, provider] of configProviders) {
|
for (const [id, provider] of configProviders) {
|
||||||
const providerID = ProviderID.make(id)
|
const providerID = ProviderID.make(id)
|
||||||
const partial: Partial<Info> = { source: "config" }
|
const partial: Partial<Info> = { source: "config" }
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,22 @@
|
||||||
import { test, expect } from "bun:test"
|
import { test, expect } from "bun:test"
|
||||||
|
import { mkdir, unlink } from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
import { Global } from "../../src/global"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { Plugin } from "../../src/plugin/index"
|
||||||
import { Provider } from "../../src/provider/provider"
|
import { Provider } from "../../src/provider/provider"
|
||||||
import { ProviderID, ModelID } from "../../src/provider/schema"
|
import { ProviderID, ModelID } from "../../src/provider/schema"
|
||||||
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
import { Env } from "../../src/env"
|
import { Env } from "../../src/env"
|
||||||
|
|
||||||
|
function paid(providers: Awaited<ReturnType<typeof Provider.list>>) {
|
||||||
|
const item = providers[ProviderID.make("opencode")]
|
||||||
|
expect(item).toBeDefined()
|
||||||
|
return Object.values(item.models).filter((model) => model.cost.input > 0).length
|
||||||
|
}
|
||||||
|
|
||||||
test("provider loaded from env variable", async () => {
|
test("provider loaded from env variable", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
|
|
@ -2282,3 +2292,203 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("plugin config providers persist after instance dispose", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const root = path.join(dir, ".opencode", "plugin")
|
||||||
|
await mkdir(root, { recursive: true })
|
||||||
|
await Bun.write(
|
||||||
|
path.join(root, "demo-provider.ts"),
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
' id: "demo.plugin-provider",',
|
||||||
|
" server: async () => ({",
|
||||||
|
" async config(cfg) {",
|
||||||
|
" cfg.provider ??= {}",
|
||||||
|
" cfg.provider.demo = {",
|
||||||
|
' name: "Demo Provider",',
|
||||||
|
' npm: "@ai-sdk/openai-compatible",',
|
||||||
|
' api: "https://example.com/v1",',
|
||||||
|
" models: {",
|
||||||
|
" chat: {",
|
||||||
|
' name: "Demo Chat",',
|
||||||
|
" tool_call: true,",
|
||||||
|
" limit: { context: 128000, output: 4096 },",
|
||||||
|
" },",
|
||||||
|
" },",
|
||||||
|
" }",
|
||||||
|
" },",
|
||||||
|
" }),",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
await Plugin.init()
|
||||||
|
return Provider.list()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(first[ProviderID.make("demo")]).toBeDefined()
|
||||||
|
expect(first[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined()
|
||||||
|
|
||||||
|
await Instance.disposeAll()
|
||||||
|
|
||||||
|
const second = await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => Provider.list(),
|
||||||
|
})
|
||||||
|
expect(second[ProviderID.make("demo")]).toBeDefined()
|
||||||
|
expect(second[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("plugin config enabled and disabled providers are honored", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const root = path.join(dir, ".opencode", "plugin")
|
||||||
|
await mkdir(root, { recursive: true })
|
||||||
|
await Bun.write(
|
||||||
|
path.join(root, "provider-filter.ts"),
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
' id: "demo.provider-filter",',
|
||||||
|
" server: async () => ({",
|
||||||
|
" async config(cfg) {",
|
||||||
|
' cfg.enabled_providers = ["anthropic", "openai"]',
|
||||||
|
' cfg.disabled_providers = ["openai"]',
|
||||||
|
" },",
|
||||||
|
" }),",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
init: async () => {
|
||||||
|
Env.set("ANTHROPIC_API_KEY", "test-anthropic-key")
|
||||||
|
Env.set("OPENAI_API_KEY", "test-openai-key")
|
||||||
|
},
|
||||||
|
fn: async () => {
|
||||||
|
const providers = await Provider.list()
|
||||||
|
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||||
|
expect(providers[ProviderID.openai]).toBeUndefined()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("opencode loader keeps paid models when config apiKey is present", async () => {
|
||||||
|
await using base = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const none = await Instance.provide({
|
||||||
|
directory: base.path,
|
||||||
|
fn: async () => paid(await Provider.list()),
|
||||||
|
})
|
||||||
|
|
||||||
|
await using keyed = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
provider: {
|
||||||
|
opencode: {
|
||||||
|
options: {
|
||||||
|
apiKey: "test-key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const keyedCount = await Instance.provide({
|
||||||
|
directory: keyed.path,
|
||||||
|
fn: async () => paid(await Provider.list()),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(none).toBe(0)
|
||||||
|
expect(keyedCount).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("opencode loader keeps paid models when auth exists", async () => {
|
||||||
|
await using base = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const none = await Instance.provide({
|
||||||
|
directory: base.path,
|
||||||
|
fn: async () => paid(await Provider.list()),
|
||||||
|
})
|
||||||
|
|
||||||
|
await using keyed = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const authPath = path.join(Global.Path.data, "auth.json")
|
||||||
|
let prev: string | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
prev = await Filesystem.readText(authPath)
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Filesystem.write(
|
||||||
|
authPath,
|
||||||
|
JSON.stringify({
|
||||||
|
opencode: {
|
||||||
|
type: "api",
|
||||||
|
key: "test-key",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyedCount = await Instance.provide({
|
||||||
|
directory: keyed.path,
|
||||||
|
fn: async () => paid(await Provider.list()),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(none).toBe(0)
|
||||||
|
expect(keyedCount).toBeGreaterThan(0)
|
||||||
|
} finally {
|
||||||
|
if (prev !== undefined) {
|
||||||
|
await Filesystem.write(authPath, prev)
|
||||||
|
}
|
||||||
|
if (prev === undefined) {
|
||||||
|
try {
|
||||||
|
await unlink(authPath)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue