diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index afd69f9e70..924d13312a 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) => Promise type CustomVarsLoader = (options: Record) => Record type CustomDiscoverModels = () => Promise> - 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 + config: () => Effect.Effect + } + function useLanguageModel(sdk: any) { return sdk.responses === undefined && sdk.chat === undefined } - const CUSTOM_LOADERS: Record = { - 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 { + 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 - })() + 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 (!hasKey) { - for (const [key, value] of Object.entries(input.models)) { - if (value.cost.input === 0) continue - delete input.models[key] + 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) { - return sdk.responses(modelID) - }, - options: {}, - } - }, - xai: async () => { - return { - autoload: false, - async getModel(sdk: any, modelID: string, _options?: Record) { - return sdk.responses(modelID) - }, - options: {}, - } - }, - "github-copilot": async () => { - return { - autoload: false, - async getModel(sdk: any, modelID: string, _options?: Record) { - 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) { - 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": async () => { - const resourceName = Env.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME") - return { - autoload: false, - async getModel(sdk: any, modelID: string, options?: Record) { - 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": 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 { + autoload: Object.keys(input.models).length > 0, + options: ok ? {} : { apiKey: "public" }, } - return undefined - }) + }), + openai: () => + Effect.succeed({ + autoload: false, + async getModel(sdk: any, modelID: string, _options?: Record) { + return sdk.responses(modelID) + }, + options: {}, + }), + xai: () => + Effect.succeed({ + autoload: false, + async getModel(sdk: any, modelID: string, _options?: Record) { + return sdk.responses(modelID) + }, + options: {}, + }), + "github-copilot": () => + Effect.succeed({ + autoload: false, + async getModel(sdk: any, modelID: string, _options?: Record) { + 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) { + 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) { + 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") - const containerCreds = Boolean( - process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI, - ) + // 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" - if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile && !containerCreds) - return { autoload: false } + // Profile: config file takes precedence over env var + const configProfile = providerConfig?.options?.profile + const envProfile = Env.get("AWS_PROFILE") + const profile = configProfile ?? envProfile - const providerOptions: AmazonBedrockProviderSettings = { - region: defaultRegion, - } + const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID") - // 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 } : {} + // 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 + }) - providerOptions.credentialProvider = fromNodeProviderChain(credentialProviderOptions) - } + const awsWebIdentityTokenFile = Env.get("AWS_WEB_IDENTITY_TOKEN_FILE") - // Add custom endpoint if specified (endpoint takes precedence over baseURL) - const endpoint = providerConfig?.options?.endpoint ?? providerConfig?.options?.baseURL - if (endpoint) { - providerOptions.baseURL = endpoint - } + const containerCreds = Boolean( + process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI, + ) + + if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile && !containerCreds) + return { autoload: false } + + const providerOptions: AmazonBedrockProviderSettings = { + region: defaultRegion, + } + + // 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 } : {} + + providerOptions.credentialProvider = fromNodeProviderChain(credentialProviderOptions) + } + + // Add custom endpoint if specified (endpoint takes precedence over baseURL) + const endpoint = providerConfig?.options?.endpoint ?? providerConfig?.options?.baseURL + if (endpoint) { + providerOptions.baseURL = endpoint + } + + return { + autoload: true, + options: providerOptions, + async getModel(sdk: any, modelID: string, options?: Record) { + // 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) + } + + // 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 + } + 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}` + } + 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 + } + } - return { - autoload: true, - options: providerOptions, - async getModel(sdk: any, modelID: string, options?: Record) { - // 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) - } - - // 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 - } - 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}` - } - 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 - } - } - - return sdk.languageModel(modelID) - }, - } - }, - openrouter: async () => { - return { - autoload: false, - options: { - headers: { - "HTTP-Referer": "https://opencode.ai/", - "X-Title": "opencode", }, - }, - } - }, - vercel: async () => { - return { - 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", - ) - - const autoload = Boolean(project) - if (!autoload) return { autoload: false } - return { - autoload: true, - vars(_options: Record) { - 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": 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", + }), + openrouter: () => + 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" + }), + 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 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 location = String( + provider.options?.location ?? + Env.get("GOOGLE_VERTEX_LOCATION") ?? + Env.get("GOOGLE_CLOUD_LOCATION") ?? + Env.get("VERTEX_LOCATION") ?? + "us-central1", + ) - 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 || {}), - } - - const featureFlags = { - duo_agent_platform_agentic_chat: true, - duo_agent_platform: true, - ...(providerConfig?.options?.featureFlags || {}), - } - - return { - autoload: !!apiKey, - options: { - instanceUrl, - apiKey, - aiGatewayHeaders, - featureFlags, - }, - async getModel(sdk: ReturnType, modelID: string, options?: Record) { - 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 + const autoload = Boolean(project) + if (!autoload) return Effect.succeed({ autoload: false }) + return Effect.succeed({ + autoload: true, + vars(_options: Record) { + 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, } - return model + }, + 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 } - return sdk.agenticChat(modelID, { + 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: () => + Effect.succeed({ + autoload: false, + options: { + headers: { + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }, + }, + }), + 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 providerConfig = (yield* dep.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 || {}), + } + + const featureFlags = { + duo_agent_platform_agentic_chat: true, + duo_agent_platform: true, + ...(providerConfig?.options?.featureFlags || {}), + } + + return { + autoload: !!apiKey, + options: { + instanceUrl, + apiKey, aiGatewayHeaders, featureFlags, - }) - }, - async discoverModels(): Promise> { - if (!apiKey) { - log.info("gitlab model discovery skipped: no apiKey") - return {} - } - - try { - const token = apiKey - const getHeaders = (): Record => - 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, + }, + async getModel(sdk: ReturnType, modelID: string, options?: Record) { + 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> { + if (!apiKey) { + log.info("gitlab model discovery skipped: no apiKey") return {} } - const models: Record = {} - 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 => + 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 = {} + 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 {} } - - 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 - }) - - 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 - })() - - 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") - - 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()})`, - }, - } + }), + "cloudflare-workers-ai": Effect.fnUntraced(function* (input: Info) { + const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID") + if (!accountId) return { autoload: false } - const aigateway = createAiGateway({ - accountId, - gateway, - apiKey: apiToken, - ...(Object.values(opts).some((v) => v !== undefined) ? { options: opts } : {}), - }) - const unified = createUnified() + 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: true, - async getModel(_sdk: any, modelID: string, _options?: Record) { - // 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: !!apiKey, + options: { + apiKey, + headers: { + "User-Agent": `opencode/${Installation.VERSION} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`, + }, }, - }, - } - }, - kilo: async () => { - return { - autoload: false, - options: { - headers: { - "HTTP-Referer": "https://opencode.ai/", - "X-Title": "opencode", + 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`.", + ) + } + + // 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 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() + + return { + autoload: true, + async getModel(_sdk: any, modelID: string, _options?: Record) { + // Model IDs use Unified API format: provider/model (e.g., "anthropic/claude-sonnet-4-5") + return aigateway(unified(modelID)) + }, + 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 = {} as Record const languages = new Map() 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) { 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 = 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 = { source: "config" } diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 72ba9dba5a..88e9ea64c9 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>) { + 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 {} + } + } +})