summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-03 20:24:24 -0400
committerGitHub <[email protected]>2026-04-04 00:24:24 +0000
commit59ca4543d80148122127666c22a47e0b24180549 (patch)
tree987d4faccadc24ed7566767d8984e0dd4f47ee93
parent650d0dbe54a565684a7878a263c4521170d8365a (diff)
downloadopencode-59ca4543d80148122127666c22a47e0b24180549.tar.gz
opencode-59ca4543d80148122127666c22a47e0b24180549.zip
refactor(provider): stop custom loaders using facades (#20776)
Co-authored-by: luanweslley77 <[email protected]>
-rw-r--r--packages/opencode/src/provider/provider.ts1177
-rw-r--r--packages/opencode/test/provider/provider.test.ts210
2 files changed, 802 insertions, 585 deletions
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index afd69f9e7..924d13312 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -152,7 +152,7 @@ export namespace Provider {
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
type CustomVarsLoader = (options: Record<string, any>) => Record<string, string>
type CustomDiscoverModels = () => Promise<Record<string, Model>>
- type CustomLoader = (provider: Info) => Promise<{
+ type CustomLoader = (provider: Info) => Effect.Effect<{
autoload: boolean
getModel?: CustomModelLoader
vars?: CustomVarsLoader
@@ -160,632 +160,629 @@ export namespace Provider {
discoverModels?: CustomDiscoverModels
}>
+ type CustomDep = {
+ auth: (id: string) => Effect.Effect<Auth.Info | undefined>
+ config: () => Effect.Effect<Config.Info>
+ }
+
function useLanguageModel(sdk: any) {
return sdk.responses === undefined && sdk.chat === undefined
}
- const CUSTOM_LOADERS: Record<string, CustomLoader> = {
- async anthropic() {
- return {
- autoload: false,
- options: {
- headers: {
- "anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
+ function custom(dep: CustomDep): Record<string, CustomLoader> {
+ return {
+ anthropic: () =>
+ Effect.succeed({
+ autoload: false,
+ options: {
+ headers: {
+ "anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
+ },
},
- },
- }
- },
- async opencode(input) {
- const hasKey = await (async () => {
+ }),
+ opencode: Effect.fnUntraced(function* (input: Info) {
const env = Env.all()
- 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
- })()
-
- if (!hasKey) {
- for (const [key, value] of Object.entries(input.models)) {
- if (value.cost.input === 0) continue
- delete input.models[key]
+ const hasKey = iife(() => {
+ if (input.env.some((item) => env[item])) return true
+ return false
+ })
+ const ok =
+ hasKey ||
+ Boolean(yield* dep.auth(input.id)) ||
+ Boolean((yield* dep.config()).provider?.["opencode"]?.options?.apiKey)
+
+ if (!ok) {
+ for (const [key, value] of Object.entries(input.models)) {
+ if (value.cost.input === 0) continue
+ delete input.models[key]
+ }
}
- }
- return {
- autoload: Object.keys(input.models).length > 0,
- options: hasKey ? {} : { apiKey: "public" },
- }
- },
- openai: async () => {
- return {
- autoload: false,
- async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
- return sdk.responses(modelID)
- },
- options: {},
- }
- },
- xai: async () => {
- return {
- autoload: false,
- async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
- return sdk.responses(modelID)
- },
- options: {},
- }
- },
- "github-copilot": async () => {
- return {
- autoload: false,
- async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
- if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
- return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
- },
- options: {},
- }
- },
- azure: async (provider) => {
- const resource = iife(() => {
- const name = provider.options?.resourceName
- if (typeof name === "string" && name.trim() !== "") return name
- return Env.get("AZURE_RESOURCE_NAME")
- })
-
- return {
- autoload: false,
- async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
- if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
- if (options?.["useCompletionUrls"]) {
- return sdk.chat(modelID)
- } else {
+ return {
+ autoload: Object.keys(input.models).length > 0,
+ options: ok ? {} : { apiKey: "public" },
+ }
+ }),
+ openai: () =>
+ Effect.succeed({
+ autoload: false,
+ async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
return sdk.responses(modelID)
- }
- },
- options: {},
- vars(_options) {
- return {
- ...(resource && { AZURE_RESOURCE_NAME: resource }),
- }
- },
- }
- },
- "azure-cognitive-services": async () => {
- const resourceName = Env.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
- return {
- autoload: false,
- async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
- if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
- if (options?.["useCompletionUrls"]) {
- return sdk.chat(modelID)
- } else {
+ },
+ options: {},
+ }),
+ xai: () =>
+ Effect.succeed({
+ autoload: false,
+ async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
return sdk.responses(modelID)
- }
- },
- options: {
- baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
- },
- }
- },
- "amazon-bedrock": async () => {
- const config = await Config.get()
- const providerConfig = config.provider?.["amazon-bedrock"]
-
- const auth = await Auth.get("amazon-bedrock")
-
- // Region precedence: 1) config file, 2) env var, 3) default
- const configRegion = providerConfig?.options?.region
- const envRegion = Env.get("AWS_REGION")
- const defaultRegion = configRegion ?? envRegion ?? "us-east-1"
-
- // Profile: config file takes precedence over env var
- const configProfile = providerConfig?.options?.profile
- const envProfile = Env.get("AWS_PROFILE")
- const profile = configProfile ?? envProfile
-
- const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID")
-
- // TODO: Using process.env directly because Env.set only updates a process.env shallow copy,
- // until the scope of the Env API is clarified (test only or runtime?)
- const awsBearerToken = iife(() => {
- const envToken = process.env.AWS_BEARER_TOKEN_BEDROCK
- if (envToken) return envToken
- if (auth?.type === "api") {
- process.env.AWS_BEARER_TOKEN_BEDROCK = auth.key
- return auth.key
- }
- return undefined
- })
+ },
+ options: {},
+ }),
+ "github-copilot": () =>
+ Effect.succeed({
+ autoload: false,
+ async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
+ if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
+ return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
+ },
+ options: {},
+ }),
+ azure: (provider) => {
+ const resource = iife(() => {
+ const name = provider.options?.resourceName
+ if (typeof name === "string" && name.trim() !== "") return name
+ return Env.get("AZURE_RESOURCE_NAME")
+ })
- const awsWebIdentityTokenFile = Env.get("AWS_WEB_IDENTITY_TOKEN_FILE")
+ return Effect.succeed({
+ autoload: false,
+ async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
+ if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
+ if (options?.["useCompletionUrls"]) {
+ return sdk.chat(modelID)
+ } else {
+ return sdk.responses(modelID)
+ }
+ },
+ options: {},
+ vars(_options) {
+ return {
+ ...(resource && { AZURE_RESOURCE_NAME: resource }),
+ }
+ },
+ })
+ },
+ "azure-cognitive-services": () => {
+ const resourceName = Env.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
+ return Effect.succeed({
+ autoload: false,
+ async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
+ if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
+ if (options?.["useCompletionUrls"]) {
+ return sdk.chat(modelID)
+ } else {
+ return sdk.responses(modelID)
+ }
+ },
+ options: {
+ baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
+ },
+ })
+ },
+ "amazon-bedrock": Effect.fnUntraced(function* () {
+ const providerConfig = (yield* dep.config()).provider?.["amazon-bedrock"]
+ const auth = yield* dep.auth("amazon-bedrock")
+
+ // Region precedence: 1) config file, 2) env var, 3) default
+ const configRegion = providerConfig?.options?.region
+ const envRegion = Env.get("AWS_REGION")
+ const defaultRegion = configRegion ?? envRegion ?? "us-east-1"
+
+ // Profile: config file takes precedence over env var
+ const configProfile = providerConfig?.options?.profile
+ const envProfile = Env.get("AWS_PROFILE")
+ const profile = configProfile ?? envProfile
+
+ const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID")
+
+ // TODO: Using process.env directly because Env.set only updates a process.env shallow copy,
+ // until the scope of the Env API is clarified (test only or runtime?)
+ const awsBearerToken = iife(() => {
+ const envToken = process.env.AWS_BEARER_TOKEN_BEDROCK
+ if (envToken) return envToken
+ if (auth?.type === "api") {
+ process.env.AWS_BEARER_TOKEN_BEDROCK = auth.key
+ return auth.key
+ }
+ return undefined
+ })
- const containerCreds = Boolean(
- process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
- )
+ const awsWebIdentityTokenFile = Env.get("AWS_WEB_IDENTITY_TOKEN_FILE")
- if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile && !containerCreds)
- return { autoload: false }
+ const containerCreds = Boolean(
+ process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
+ )
- const providerOptions: AmazonBedrockProviderSettings = {
- region: defaultRegion,
- }
+ if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile && !containerCreds)
+ return { autoload: false }
- // Only use credential chain if no bearer token exists
- // Bearer token takes precedence over credential chain (profiles, access keys, IAM roles, web identity tokens)
- if (!awsBearerToken) {
- // Build credential provider options (only pass profile if specified)
- const credentialProviderOptions = profile ? { profile } : {}
+ const providerOptions: AmazonBedrockProviderSettings = {
+ region: defaultRegion,
+ }
- providerOptions.credentialProvider = fromNodeProviderChain(credentialProviderOptions)
- }
+ // Only use credential chain if no bearer token exists
+ // Bearer token takes precedence over credential chain (profiles, access keys, IAM roles, web identity tokens)
+ if (!awsBearerToken) {
+ // Build credential provider options (only pass profile if specified)
+ const credentialProviderOptions = profile ? { profile } : {}
- // Add custom endpoint if specified (endpoint takes precedence over baseURL)
- const endpoint = providerConfig?.options?.endpoint ?? providerConfig?.options?.baseURL
- if (endpoint) {
- providerOptions.baseURL = endpoint
- }
+ providerOptions.credentialProvider = fromNodeProviderChain(credentialProviderOptions)
+ }
- return {
- autoload: true,
- options: providerOptions,
- async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
- // Skip region prefixing if model already has a cross-region inference profile prefix
- // Models from models.dev may already include prefixes like us., eu., global., etc.
- const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
- if (crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))) {
- return sdk.languageModel(modelID)
- }
+ // Add custom endpoint if specified (endpoint takes precedence over baseURL)
+ const endpoint = providerConfig?.options?.endpoint ?? providerConfig?.options?.baseURL
+ if (endpoint) {
+ providerOptions.baseURL = endpoint
+ }
- // Region resolution precedence (highest to lowest):
- // 1. options.region from opencode.json provider config
- // 2. defaultRegion from AWS_REGION environment variable
- // 3. Default "us-east-1" (baked into defaultRegion)
- const region = options?.region ?? defaultRegion
-
- let regionPrefix = region.split("-")[0]
-
- switch (regionPrefix) {
- case "us": {
- const modelRequiresPrefix = [
- "nova-micro",
- "nova-lite",
- "nova-pro",
- "nova-premier",
- "nova-2",
- "claude",
- "deepseek",
- ].some((m) => modelID.includes(m))
- const isGovCloud = region.startsWith("us-gov")
- if (modelRequiresPrefix && !isGovCloud) {
- modelID = `${regionPrefix}.${modelID}`
- }
- break
+ return {
+ autoload: true,
+ options: providerOptions,
+ async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
+ // Skip region prefixing if model already has a cross-region inference profile prefix
+ // Models from models.dev may already include prefixes like us., eu., global., etc.
+ const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
+ if (crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))) {
+ return sdk.languageModel(modelID)
}
- case "eu": {
- const regionRequiresPrefix = [
- "eu-west-1",
- "eu-west-2",
- "eu-west-3",
- "eu-north-1",
- "eu-central-1",
- "eu-south-1",
- "eu-south-2",
- ].some((r) => region.includes(r))
- const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((m) =>
- modelID.includes(m),
- )
- if (regionRequiresPrefix && modelRequiresPrefix) {
- modelID = `${regionPrefix}.${modelID}`
+
+ // Region resolution precedence (highest to lowest):
+ // 1. options.region from opencode.json provider config
+ // 2. defaultRegion from AWS_REGION environment variable
+ // 3. Default "us-east-1" (baked into defaultRegion)
+ const region = options?.region ?? defaultRegion
+
+ let regionPrefix = region.split("-")[0]
+
+ switch (regionPrefix) {
+ case "us": {
+ const modelRequiresPrefix = [
+ "nova-micro",
+ "nova-lite",
+ "nova-pro",
+ "nova-premier",
+ "nova-2",
+ "claude",
+ "deepseek",
+ ].some((m) => modelID.includes(m))
+ const isGovCloud = region.startsWith("us-gov")
+ if (modelRequiresPrefix && !isGovCloud) {
+ modelID = `${regionPrefix}.${modelID}`
+ }
+ break
}
- break
- }
- case "ap": {
- const isAustraliaRegion = ["ap-southeast-2", "ap-southeast-4"].includes(region)
- const isTokyoRegion = region === "ap-northeast-1"
- if (
- isAustraliaRegion &&
- ["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((m) => modelID.includes(m))
- ) {
- regionPrefix = "au"
- modelID = `${regionPrefix}.${modelID}`
- } else if (isTokyoRegion) {
- // Tokyo region uses jp. prefix for cross-region inference
- const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) =>
+ case "eu": {
+ const regionRequiresPrefix = [
+ "eu-west-1",
+ "eu-west-2",
+ "eu-west-3",
+ "eu-north-1",
+ "eu-central-1",
+ "eu-south-1",
+ "eu-south-2",
+ ].some((r) => region.includes(r))
+ const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((m) =>
modelID.includes(m),
)
- if (modelRequiresPrefix) {
- regionPrefix = "jp"
+ if (regionRequiresPrefix && modelRequiresPrefix) {
modelID = `${regionPrefix}.${modelID}`
}
- } else {
- // Other APAC regions use apac. prefix
- const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) =>
- modelID.includes(m),
- )
- if (modelRequiresPrefix) {
- regionPrefix = "apac"
+ break
+ }
+ case "ap": {
+ const isAustraliaRegion = ["ap-southeast-2", "ap-southeast-4"].includes(region)
+ const isTokyoRegion = region === "ap-northeast-1"
+ if (
+ isAustraliaRegion &&
+ ["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((m) => modelID.includes(m))
+ ) {
+ regionPrefix = "au"
modelID = `${regionPrefix}.${modelID}`
+ } else if (isTokyoRegion) {
+ // Tokyo region uses jp. prefix for cross-region inference
+ const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) =>
+ modelID.includes(m),
+ )
+ if (modelRequiresPrefix) {
+ regionPrefix = "jp"
+ modelID = `${regionPrefix}.${modelID}`
+ }
+ } else {
+ // Other APAC regions use apac. prefix
+ const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) =>
+ modelID.includes(m),
+ )
+ if (modelRequiresPrefix) {
+ regionPrefix = "apac"
+ modelID = `${regionPrefix}.${modelID}`
+ }
}
+ break
}
- break
}
- }
- return sdk.languageModel(modelID)
- },
- }
- },
- openrouter: async () => {
- return {
- autoload: false,
- options: {
- headers: {
- "HTTP-Referer": "https://opencode.ai/",
- "X-Title": "opencode",
+ return sdk.languageModel(modelID)
},
- },
- }
- },
- vercel: async () => {
- return {
- autoload: false,
- options: {
- headers: {
- "http-referer": "https://opencode.ai/",
- "x-title": "opencode",
+ }
+ }),
+ openrouter: () =>
+ Effect.succeed({
+ autoload: false,
+ options: {
+ headers: {
+ "HTTP-Referer": "https://opencode.ai/",
+ "X-Title": "opencode",
+ },
},
- },
- }
- },
- "google-vertex": async (provider) => {
- const project =
- provider.options?.project ??
- Env.get("GOOGLE_CLOUD_PROJECT") ??
- Env.get("GCP_PROJECT") ??
- Env.get("GCLOUD_PROJECT")
-
- const location = String(
- provider.options?.location ??
- Env.get("GOOGLE_VERTEX_LOCATION") ??
- Env.get("GOOGLE_CLOUD_LOCATION") ??
- Env.get("VERTEX_LOCATION") ??
- "us-central1",
- )
+ }),
+ vercel: () =>
+ Effect.succeed({
+ autoload: false,
+ options: {
+ headers: {
+ "http-referer": "https://opencode.ai/",
+ "x-title": "opencode",
+ },
+ },
+ }),
+ "google-vertex": (provider) => {
+ const project =
+ provider.options?.project ??
+ Env.get("GOOGLE_CLOUD_PROJECT") ??
+ Env.get("GCP_PROJECT") ??
+ Env.get("GCLOUD_PROJECT")
+
+ const location = String(
+ provider.options?.location ??
+ Env.get("GOOGLE_VERTEX_LOCATION") ??
+ Env.get("GOOGLE_CLOUD_LOCATION") ??
+ Env.get("VERTEX_LOCATION") ??
+ "us-central1",
+ )
- const autoload = Boolean(project)
- if (!autoload) return { autoload: false }
- return {
- autoload: true,
- vars(_options: Record<string, any>) {
- const endpoint = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`
- return {
- ...(project && { GOOGLE_VERTEX_PROJECT: project }),
- GOOGLE_VERTEX_LOCATION: location,
- GOOGLE_VERTEX_ENDPOINT: endpoint,
+ const autoload = Boolean(project)
+ if (!autoload) return Effect.succeed({ autoload: false })
+ return Effect.succeed({
+ autoload: true,
+ vars(_options: Record<string, any>) {
+ const endpoint =
+ location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`
+ return {
+ ...(project && { GOOGLE_VERTEX_PROJECT: project }),
+ GOOGLE_VERTEX_LOCATION: location,
+ GOOGLE_VERTEX_ENDPOINT: endpoint,
+ }
+ },
+ options: {
+ project,
+ location,
+ fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
+ const auth = new GoogleAuth()
+ const client = await auth.getApplicationDefault()
+ const token = await client.credential.getAccessToken()
+
+ const headers = new Headers(init?.headers)
+ headers.set("Authorization", `Bearer ${token.token}`)
+
+ return fetch(input, { ...init, headers })
+ },
+ },
+ async getModel(sdk: any, modelID: string) {
+ const id = String(modelID).trim()
+ return sdk.languageModel(id)
+ },
+ })
+ },
+ "google-vertex-anthropic": () => {
+ 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 autoload = Boolean(project)
+ if (!autoload) return Effect.succeed({ autoload: false })
+ return Effect.succeed({
+ autoload: true,
+ options: {
+ project,
+ location,
+ },
+ async getModel(sdk: any, modelID) {
+ const id = String(modelID).trim()
+ return sdk.languageModel(id)
+ },
+ })
+ },
+ "sap-ai-core": Effect.fnUntraced(function* () {
+ const auth = yield* dep.auth("sap-ai-core")
+ // 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?)
+ const envServiceKey = iife(() => {
+ const envAICoreServiceKey = process.env.AICORE_SERVICE_KEY
+ if (envAICoreServiceKey) return envAICoreServiceKey
+ if (auth?.type === "api") {
+ process.env.AICORE_SERVICE_KEY = auth.key
+ return auth.key
}
- },
- options: {
- project,
- location,
- fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
- const auth = new GoogleAuth()
- const client = await auth.getApplicationDefault()
- const token = await client.credential.getAccessToken()
-
- const headers = new Headers(init?.headers)
- headers.set("Authorization", `Bearer ${token.token}`)
-
- return fetch(input, { ...init, headers })
+ return undefined
+ })
+ const deploymentId = process.env.AICORE_DEPLOYMENT_ID
+ const resourceGroup = process.env.AICORE_RESOURCE_GROUP
+
+ return {
+ autoload: !!envServiceKey,
+ options: envServiceKey ? { deploymentId, resourceGroup } : {},
+ async getModel(sdk: any, modelID: string) {
+ return sdk(modelID)
},
- },
- async getModel(sdk: any, modelID: string) {
- const id = String(modelID).trim()
- return sdk.languageModel(id)
- },
- }
- },
- "google-vertex-anthropic": async () => {
- 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 autoload = Boolean(project)
- if (!autoload) return { autoload: false }
- return {
- autoload: true,
- options: {
- project,
- location,
- },
- async getModel(sdk: any, modelID) {
- const id = String(modelID).trim()
- return sdk.languageModel(id)
- },
- }
- },
- "sap-ai-core": async () => {
- const auth = await Auth.get("sap-ai-core")
- // 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?)
- const envServiceKey = iife(() => {
- const envAICoreServiceKey = process.env.AICORE_SERVICE_KEY
- if (envAICoreServiceKey) return envAICoreServiceKey
- if (auth?.type === "api") {
- process.env.AICORE_SERVICE_KEY = auth.key
- return auth.key
}
- return undefined
- })
- const deploymentId = process.env.AICORE_DEPLOYMENT_ID
- const resourceGroup = process.env.AICORE_RESOURCE_GROUP
-
- return {
- autoload: !!envServiceKey,
- options: envServiceKey ? { deploymentId, resourceGroup } : {},
- async getModel(sdk: any, modelID: string) {
- return sdk(modelID)
- },
- }
- },
- zenmux: async () => {
- return {
- autoload: false,
- options: {
- headers: {
- "HTTP-Referer": "https://opencode.ai/",
- "X-Title": "opencode",
+ }),
+ zenmux: () =>
+ Effect.succeed({
+ autoload: false,
+ options: {
+ headers: {
+ "HTTP-Referer": "https://opencode.ai/",
+ "X-Title": "opencode",
+ },
},
- },
- }
- },
- gitlab: async (input) => {
- const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com"
-
- const auth = await Auth.get(input.id)
- const apiKey = await (async () => {
- if (auth?.type === "oauth") return auth.access
- if (auth?.type === "api") return auth.key
- return Env.get("GITLAB_TOKEN")
- })()
-
- const config = await Config.get()
- const providerConfig = config.provider?.["gitlab"]
-
- const aiGatewayHeaders = {
- "User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
- "anthropic-beta": "context-1m-2025-08-07",
- ...(providerConfig?.options?.aiGatewayHeaders || {}),
- }
+ }),
+ gitlab: Effect.fnUntraced(function* (input: Info) {
+ const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com"
+
+ const auth = yield* dep.auth(input.id)
+ const apiKey = yield* Effect.sync(() => {
+ if (auth?.type === "oauth") return auth.access
+ if (auth?.type === "api") return auth.key
+ return Env.get("GITLAB_TOKEN")
+ })
- const featureFlags = {
- duo_agent_platform_agentic_chat: true,
- duo_agent_platform: true,
- ...(providerConfig?.options?.featureFlags || {}),
- }
+ const providerConfig = (yield* dep.config()).provider?.["gitlab"]
- return {
- autoload: !!apiKey,
- options: {
- instanceUrl,
- apiKey,
- aiGatewayHeaders,
- featureFlags,
- },
- async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string, options?: Record<string, any>) {
- if (modelID.startsWith("duo-workflow-")) {
- const workflowRef = options?.workflowRef as string | undefined
- // Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef
- const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow"
- const model = sdk.workflowChat(sdkModelID, {
- featureFlags,
- })
- if (workflowRef) {
- model.selectedModelRef = workflowRef
- }
- return model
- }
- return sdk.agenticChat(modelID, {
- aiGatewayHeaders,
- featureFlags,
- })
- },
- async discoverModels(): Promise<Record<string, Model>> {
- if (!apiKey) {
- log.info("gitlab model discovery skipped: no apiKey")
- return {}
- }
+ const aiGatewayHeaders = {
+ "User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
+ "anthropic-beta": "context-1m-2025-08-07",
+ ...(providerConfig?.options?.aiGatewayHeaders || {}),
+ }
- try {
- const token = apiKey
- const getHeaders = (): Record<string, string> =>
- auth?.type === "api" ? { "PRIVATE-TOKEN": token } : { Authorization: `Bearer ${token}` }
-
- log.info("gitlab model discovery starting", { instanceUrl })
- const result = await discoverWorkflowModels(
- { instanceUrl, getHeaders },
- { workingDirectory: Instance.directory },
- )
+ const featureFlags = {
+ duo_agent_platform_agentic_chat: true,
+ duo_agent_platform: true,
+ ...(providerConfig?.options?.featureFlags || {}),
+ }
- if (!result.models.length) {
- log.info("gitlab model discovery skipped: no models found", {
- project: result.project
- ? {
- id: result.project.id,
- path: result.project.pathWithNamespace,
- }
- : null,
+ return {
+ autoload: !!apiKey,
+ options: {
+ instanceUrl,
+ apiKey,
+ aiGatewayHeaders,
+ featureFlags,
+ },
+ async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string, options?: Record<string, any>) {
+ if (modelID.startsWith("duo-workflow-")) {
+ const workflowRef = options?.workflowRef as string | undefined
+ // Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef
+ const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow"
+ const model = sdk.workflowChat(sdkModelID, {
+ featureFlags,
})
+ if (workflowRef) {
+ model.selectedModelRef = workflowRef
+ }
+ return model
+ }
+ return sdk.agenticChat(modelID, {
+ aiGatewayHeaders,
+ featureFlags,
+ })
+ },
+ async discoverModels(): Promise<Record<string, Model>> {
+ if (!apiKey) {
+ log.info("gitlab model discovery skipped: no apiKey")
return {}
}
- const models: Record<string, Model> = {}
- for (const m of result.models) {
- if (!input.models[m.id]) {
- models[m.id] = {
- id: ModelID.make(m.id),
- providerID: ProviderID.make("gitlab"),
- name: `Agent Platform (${m.name})`,
- family: "",
- api: {
- id: m.id,
- url: instanceUrl,
- npm: "gitlab-ai-provider",
- },
- status: "active",
- headers: {},
- options: { workflowRef: m.ref },
- cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
- limit: { context: m.context, output: m.output },
- capabilities: {
- temperature: false,
- reasoning: true,
- attachment: true,
- toolcall: true,
- input: {
- text: true,
- audio: false,
- image: true,
- video: false,
- pdf: true,
+ try {
+ const token = apiKey
+ const getHeaders = (): Record<string, string> =>
+ auth?.type === "api" ? { "PRIVATE-TOKEN": token } : { Authorization: `Bearer ${token}` }
+
+ log.info("gitlab model discovery starting", { instanceUrl })
+ const result = await discoverWorkflowModels(
+ { instanceUrl, getHeaders },
+ { workingDirectory: Instance.directory },
+ )
+
+ if (!result.models.length) {
+ log.info("gitlab model discovery skipped: no models found", {
+ project: result.project
+ ? {
+ id: result.project.id,
+ path: result.project.pathWithNamespace,
+ }
+ : null,
+ })
+ return {}
+ }
+
+ const models: Record<string, Model> = {}
+ for (const m of result.models) {
+ if (!input.models[m.id]) {
+ models[m.id] = {
+ id: ModelID.make(m.id),
+ providerID: ProviderID.make("gitlab"),
+ name: `Agent Platform (${m.name})`,
+ family: "",
+ api: {
+ id: m.id,
+ url: instanceUrl,
+ npm: "gitlab-ai-provider",
},
- output: {
- text: true,
- audio: false,
- image: false,
- video: false,
- pdf: false,
+ status: "active",
+ headers: {},
+ options: { workflowRef: m.ref },
+ cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
+ limit: { context: m.context, output: m.output },
+ capabilities: {
+ temperature: false,
+ reasoning: true,
+ attachment: true,
+ toolcall: true,
+ input: {
+ text: true,
+ audio: false,
+ image: true,
+ video: false,
+ pdf: true,
+ },
+ output: {
+ text: true,
+ audio: false,
+ image: false,
+ video: false,
+ pdf: false,
+ },
+ interleaved: false,
},
- interleaved: false,
- },
- release_date: "",
- variants: {},
+ release_date: "",
+ variants: {},
+ }
}
}
- }
- log.info("gitlab model discovery complete", {
- count: Object.keys(models).length,
- models: Object.keys(models),
- })
- return models
- } catch (e) {
- log.warn("gitlab model discovery failed", { error: e })
- return {}
- }
- },
- }
- },
- "cloudflare-workers-ai": async (input) => {
- const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
- if (!accountId) return { autoload: false }
-
- const apiKey = await iife(async () => {
- const envToken = Env.get("CLOUDFLARE_API_KEY")
- if (envToken) return envToken
- const auth = await Auth.get(input.id)
- if (auth?.type === "api") return auth.key
- return undefined
- })
+ log.info("gitlab model discovery complete", {
+ count: Object.keys(models).length,
+ models: Object.keys(models),
+ })
+ return models
+ } catch (e) {
+ log.warn("gitlab model discovery failed", { error: e })
+ return {}
+ }
+ },
+ }
+ }),
+ "cloudflare-workers-ai": Effect.fnUntraced(function* (input: Info) {
+ const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
+ if (!accountId) return { autoload: false }
+
+ const apiKey = yield* Effect.gen(function* () {
+ const envToken = Env.get("CLOUDFLARE_API_KEY")
+ if (envToken) return envToken
+ const auth = yield* dep.auth(input.id)
+ if (auth?.type === "api") return auth.key
+ return undefined
+ })
- return {
- autoload: !!apiKey,
- options: {
- apiKey,
- headers: {
- "User-Agent": `opencode/${Installation.VERSION} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
+ return {
+ autoload: !!apiKey,
+ options: {
+ apiKey,
+ headers: {
+ "User-Agent": `opencode/${Installation.VERSION} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
+ },
},
- },
- async getModel(sdk: any, modelID: string) {
- return sdk.languageModel(modelID)
- },
- vars(_options) {
- return {
- CLOUDFLARE_ACCOUNT_ID: accountId,
- }
- },
- }
- },
- "cloudflare-ai-gateway": async (input) => {
- const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
- const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")
-
- if (!accountId || !gateway) return { autoload: false }
-
- // Get API token from env or auth - required for authenticated gateways
- const apiToken = await (async () => {
- const envToken = Env.get("CLOUDFLARE_API_TOKEN") || Env.get("CF_AIG_TOKEN")
- if (envToken) return envToken
- const auth = await Auth.get(input.id)
- if (auth?.type === "api") return auth.key
- return undefined
- })()
+ async getModel(sdk: any, modelID: string) {
+ return sdk.languageModel(modelID)
+ },
+ vars(_options) {
+ return {
+ CLOUDFLARE_ACCOUNT_ID: accountId,
+ }
+ },
+ }
+ }),
+ "cloudflare-ai-gateway": Effect.fnUntraced(function* (input: Info) {
+ const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
+ const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")
+
+ if (!accountId || !gateway) return { autoload: false }
+
+ // Get API token from env or auth - required for authenticated gateways
+ const apiToken = yield* Effect.gen(function* () {
+ const envToken = Env.get("CLOUDFLARE_API_TOKEN") || Env.get("CF_AIG_TOKEN")
+ if (envToken) return envToken
+ const auth = yield* dep.auth(input.id)
+ if (auth?.type === "api") return auth.key
+ return undefined
+ })
- if (!apiToken) {
- throw new Error(
- "CLOUDFLARE_API_TOKEN (or CF_AIG_TOKEN) is required for Cloudflare AI Gateway. " +
- "Set it via environment variable or run `opencode auth cloudflare-ai-gateway`.",
- )
- }
+ if (!apiToken) {
+ throw new Error(
+ "CLOUDFLARE_API_TOKEN (or CF_AIG_TOKEN) is required for Cloudflare AI Gateway. " +
+ "Set it via environment variable or run `opencode auth cloudflare-ai-gateway`.",
+ )
+ }
- // Use official ai-gateway-provider package (v2.x for AI SDK v5 compatibility)
- const { createAiGateway } = await import("ai-gateway-provider")
- const { createUnified } = await import("ai-gateway-provider/providers/unified")
+ // Use official ai-gateway-provider package (v2.x for AI SDK v5 compatibility)
+ const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider"))
+ const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified"))
- const metadata = iife(() => {
- if (input.options?.metadata) return input.options.metadata
- try {
- return JSON.parse(input.options?.headers?.["cf-aig-metadata"])
- } catch {
- return undefined
+ const metadata = iife(() => {
+ if (input.options?.metadata) return input.options.metadata
+ try {
+ return JSON.parse(input.options?.headers?.["cf-aig-metadata"])
+ } catch {
+ return undefined
+ }
+ })
+ const opts = {
+ metadata,
+ cacheTtl: input.options?.cacheTtl,
+ cacheKey: input.options?.cacheKey,
+ skipCache: input.options?.skipCache,
+ collectLog: input.options?.collectLog,
+ headers: {
+ "User-Agent": `opencode/${Installation.VERSION} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`,
+ },
}
- })
- const opts = {
- metadata,
- cacheTtl: input.options?.cacheTtl,
- cacheKey: input.options?.cacheKey,
- skipCache: input.options?.skipCache,
- collectLog: input.options?.collectLog,
- headers: {
- "User-Agent": `opencode/${Installation.VERSION} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`,
- },
- }
- const aigateway = createAiGateway({
- accountId,
- gateway,
- apiKey: apiToken,
- ...(Object.values(opts).some((v) => v !== undefined) ? { options: opts } : {}),
- })
- const unified = createUnified()
+ const aigateway = createAiGateway({
+ accountId,
+ gateway,
+ apiKey: apiToken,
+ ...(Object.values(opts).some((v) => v !== undefined) ? { options: opts } : {}),
+ })
+ const unified = createUnified()
- return {
- autoload: true,
- async getModel(_sdk: any, modelID: string, _options?: Record<string, any>) {
- // Model IDs use Unified API format: provider/model (e.g., "anthropic/claude-sonnet-4-5")
- return aigateway(unified(modelID))
- },
- options: {},
- }
- },
- cerebras: async () => {
- return {
- autoload: false,
- options: {
- headers: {
- "X-Cerebras-3rd-Party-Integration": "opencode",
+ return {
+ autoload: true,
+ async getModel(_sdk: any, modelID: string, _options?: Record<string, any>) {
+ // Model IDs use Unified API format: provider/model (e.g., "anthropic/claude-sonnet-4-5")
+ return aigateway(unified(modelID))
},
- },
- }
- },
- kilo: async () => {
- return {
- autoload: false,
- options: {
- headers: {
- "HTTP-Referer": "https://opencode.ai/",
- "X-Title": "opencode",
+ options: {},
+ }
+ }),
+ cerebras: () =>
+ Effect.succeed({
+ autoload: false,
+ options: {
+ headers: {
+ "X-Cerebras-3rd-Party-Integration": "opencode",
+ },
},
- },
- }
- },
+ }),
+ kilo: () =>
+ Effect.succeed({
+ autoload: false,
+ options: {
+ headers: {
+ "HTTP-Referer": "https://opencode.ai/",
+ "X-Title": "opencode",
+ },
+ },
+ }),
+ }
}
export const Model = z
@@ -989,15 +986,6 @@ export namespace Provider {
const modelsDev = yield* Effect.promise(() => ModelsDev.get())
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 languages = new Map<string, LanguageModelV3>()
const modelLoaders: {
@@ -1010,11 +998,13 @@ export namespace Provider {
const discoveryLoaders: {
[providerID: string]: CustomDiscoverModels
} = {}
+ const dep = {
+ auth: (id: string) => auth.get(id).pipe(Effect.orDie),
+ config: () => config.get(),
+ }
log.info("init")
- const configProviders = Object.entries(cfg.provider ?? {})
-
function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
const existing = providers[providerID]
if (existing) {
@@ -1028,6 +1018,20 @@ export namespace 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
for (const [providerID, provider] of configProviders) {
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) {
if (!plugin.auth) continue
const providerID = ProviderID.make(plugin.auth.provider)
if (disabled.has(providerID)) continue
- const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
- if (!pluginAuth) continue
+ const stored = yield* auth.get(providerID).pipe(Effect.orDie)
+ if (!stored) continue
if (!plugin.auth.loader) continue
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 patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
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)
if (disabled.has(providerID)) continue
const data = database[providerID]
@@ -1170,7 +1177,7 @@ export namespace Provider {
log.error("Provider does not exist in model list " + providerID)
continue
}
- const result = yield* Effect.promise(() => fn(data))
+ const result = yield* fn(data)
if (result && (result.autoload || providers[providerID])) {
if (result.getModel) modelLoaders[providerID] = result.getModel
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) {
const providerID = ProviderID.make(id)
const partial: Partial<Info> = { source: "config" }
diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts
index 72ba9dba5..88e9ea64c 100644
--- a/packages/opencode/test/provider/provider.test.ts
+++ b/packages/opencode/test/provider/provider.test.ts
@@ -1,12 +1,22 @@
import { test, expect } from "bun:test"
+import { mkdir, unlink } from "fs/promises"
import path from "path"
import { tmpdir } from "../fixture/fixture"
+import { Global } from "../../src/global"
import { Instance } from "../../src/project/instance"
+import { Plugin } from "../../src/plugin/index"
import { Provider } from "../../src/provider/provider"
import { ProviderID, ModelID } from "../../src/provider/schema"
+import { Filesystem } from "../../src/util/filesystem"
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 () => {
await using tmp = await tmpdir({
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 {}
+ }
+ }
+})