| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730 |
- import os from "os"
- import fuzzysort from "fuzzysort"
- import { Config } from "../config"
- import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
- import { NoSuchModelError, type Provider as SDK } from "ai"
- import { Log } from "../util"
- import { Npm } from "@opencode-ai/core/npm"
- import { Hash } from "@opencode-ai/core/util/hash"
- import { Plugin } from "../plugin"
- import { type LanguageModelV3 } from "@ai-sdk/provider"
- import * as ModelsDev from "./models"
- import { Auth } from "../auth"
- import { Env } from "../env"
- import { InstallationVersion } from "@opencode-ai/core/installation/version"
- import { Flag } from "@opencode-ai/core/flag/flag"
- import { zod } from "@/util/effect-zod"
- import { namedSchemaError } from "@/util/named-schema-error"
- import { iife } from "@/util/iife"
- import { Global } from "@opencode-ai/core/global"
- import path from "path"
- import { pathToFileURL } from "url"
- import { Effect, Layer, Context, Schema, Types } from "effect"
- import { EffectBridge } from "@/effect"
- import { InstanceState } from "@/effect"
- import { AppFileSystem } from "@opencode-ai/core/filesystem"
- import { isRecord } from "@/util/record"
- import { withStatics } from "@/util/schema"
- import * as ProviderTransform from "./transform"
- import { ModelID, ProviderID } from "./schema"
- const log = Log.create({ service: "provider" })
- function shouldUseCopilotResponsesApi(modelID: string): boolean {
- const match = /^gpt-(\d+)/.exec(modelID)
- if (!match) return false
- return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
- }
- function wrapSSE(res: Response, ms: number, ctl: AbortController) {
- if (typeof ms !== "number" || ms <= 0) return res
- if (!res.body) return res
- if (!res.headers.get("content-type")?.includes("text/event-stream")) return res
- const reader = res.body.getReader()
- const body = new ReadableStream<Uint8Array>({
- async pull(ctrl) {
- const part = await new Promise<Awaited<ReturnType<typeof reader.read>>>((resolve, reject) => {
- const id = setTimeout(() => {
- const err = new Error("SSE read timed out")
- ctl.abort(err)
- void reader.cancel(err)
- reject(err)
- }, ms)
- reader.read().then(
- (part) => {
- clearTimeout(id)
- resolve(part)
- },
- (err) => {
- clearTimeout(id)
- reject(err)
- },
- )
- })
- if (part.done) {
- ctrl.close()
- return
- }
- ctrl.enqueue(part.value)
- },
- async cancel(reason) {
- ctl.abort(reason)
- await reader.cancel(reason)
- },
- })
- return new Response(body, {
- headers: new Headers(res.headers),
- status: res.status,
- statusText: res.statusText,
- })
- }
- type BundledSDK = {
- languageModel(modelId: string): LanguageModelV3
- }
- const BUNDLED_PROVIDERS: Record<string, () => Promise<(opts: any) => BundledSDK>> = {
- "@ai-sdk/amazon-bedrock": () => import("@ai-sdk/amazon-bedrock").then((m) => m.createAmazonBedrock),
- "@ai-sdk/anthropic": () => import("@ai-sdk/anthropic").then((m) => m.createAnthropic),
- "@ai-sdk/azure": () => import("@ai-sdk/azure").then((m) => m.createAzure),
- "@ai-sdk/google": () => import("@ai-sdk/google").then((m) => m.createGoogleGenerativeAI),
- "@ai-sdk/google-vertex": () => import("@ai-sdk/google-vertex").then((m) => m.createVertex),
- "@ai-sdk/google-vertex/anthropic": () =>
- import("@ai-sdk/google-vertex/anthropic").then((m) => m.createVertexAnthropic),
- "@ai-sdk/openai": () => import("@ai-sdk/openai").then((m) => m.createOpenAI),
- "@ai-sdk/openai-compatible": () => import("@ai-sdk/openai-compatible").then((m) => m.createOpenAICompatible),
- "@openrouter/ai-sdk-provider": () => import("@openrouter/ai-sdk-provider").then((m) => m.createOpenRouter),
- "@ai-sdk/xai": () => import("@ai-sdk/xai").then((m) => m.createXai),
- "@ai-sdk/mistral": () => import("@ai-sdk/mistral").then((m) => m.createMistral),
- "@ai-sdk/groq": () => import("@ai-sdk/groq").then((m) => m.createGroq),
- "@ai-sdk/deepinfra": () => import("@ai-sdk/deepinfra").then((m) => m.createDeepInfra),
- "@ai-sdk/cerebras": () => import("@ai-sdk/cerebras").then((m) => m.createCerebras),
- "@ai-sdk/cohere": () => import("@ai-sdk/cohere").then((m) => m.createCohere),
- "@ai-sdk/gateway": () => import("@ai-sdk/gateway").then((m) => m.createGateway),
- "@ai-sdk/togetherai": () => import("@ai-sdk/togetherai").then((m) => m.createTogetherAI),
- "@ai-sdk/perplexity": () => import("@ai-sdk/perplexity").then((m) => m.createPerplexity),
- "@ai-sdk/vercel": () => import("@ai-sdk/vercel").then((m) => m.createVercel),
- "@ai-sdk/alibaba": () => import("@ai-sdk/alibaba").then((m) => m.createAlibaba),
- "gitlab-ai-provider": () => import("gitlab-ai-provider").then((m) => m.createGitLab),
- "@ai-sdk/github-copilot": () => import("./sdk/copilot").then((m) => m.createOpenaiCompatible),
- "venice-ai-sdk-provider": () => import("venice-ai-sdk-provider").then((m) => m.createVenice),
- }
- 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) => Effect.Effect<{
- autoload: boolean
- getModel?: CustomModelLoader
- vars?: CustomVarsLoader
- options?: Record<string, any>
- discoverModels?: CustomDiscoverModels
- }>
- type CustomDep = {
- auth: (id: string) => Effect.Effect<Auth.Info | undefined>
- config: () => Effect.Effect<Config.Info>
- env: () => Effect.Effect<Record<string, string | undefined>>
- get: (key: string) => Effect.Effect<string | undefined>
- }
- function useLanguageModel(sdk: any) {
- return sdk.responses === undefined && sdk.chat === undefined
- }
- 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",
- },
- },
- }),
- opencode: Effect.fnUntraced(function* (input: Info) {
- const env = yield* dep.env()
- 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: ok ? {} : { apiKey: "public" },
- }
- }),
- openai: () =>
- Effect.succeed({
- autoload: false,
- async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
- return sdk.responses(modelID)
- },
- options: {},
- }),
- xai: () =>
- Effect.succeed({
- autoload: false,
- async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
- return sdk.responses(modelID)
- },
- 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: Effect.fnUntraced(function* (provider: Info) {
- const env = yield* dep.env()
- const resource = iife(() => {
- const name = provider.options?.resourceName
- if (typeof name === "string" && name.trim() !== "") return name
- return env["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 sdk.responses(modelID)
- }
- },
- options: {},
- vars(_options) {
- return {
- ...(resource && { AZURE_RESOURCE_NAME: resource }),
- }
- },
- }
- }),
- "azure-cognitive-services": Effect.fnUntraced(function* () {
- const resourceName = yield* dep.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 {
- 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 env = yield* dep.env()
- // Region precedence: 1) config file, 2) env var, 3) default
- const configRegion = providerConfig?.options?.region
- const envRegion = env["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["AWS_PROFILE"]
- const profile = configProfile ?? envProfile
- const awsAccessKeyId = env["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 awsWebIdentityTokenFile = env["AWS_WEB_IDENTITY_TOKEN_FILE"]
- 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 { fromNodeProviderChain } = yield* Effect.promise(() => import("@aws-sdk/credential-providers"))
- const providerOptions: Record<string, any> = {
- 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<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)
- }
- // 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)
- },
- }
- }),
- llmgateway: () =>
- Effect.succeed({
- autoload: false,
- options: {
- headers: {
- "HTTP-Referer": "https://opencode.ai/",
- "X-Title": "opencode",
- "X-Source": "opencode",
- },
- },
- }),
- openrouter: () =>
- Effect.succeed({
- autoload: false,
- options: {
- headers: {
- "HTTP-Referer": "https://opencode.ai/",
- "X-Title": "opencode",
- },
- },
- }),
- nvidia: () =>
- Effect.succeed({
- autoload: false,
- options: {
- headers: {
- "HTTP-Referer": "https://opencode.ai/",
- "X-Title": "opencode",
- },
- },
- }),
- vercel: () =>
- Effect.succeed({
- autoload: false,
- options: {
- headers: {
- "http-referer": "https://opencode.ai/",
- "x-title": "opencode",
- },
- },
- }),
- "google-vertex": Effect.fnUntraced(function* (provider: Info) {
- const env = yield* dep.env()
- const project =
- provider.options?.project ?? env["GOOGLE_CLOUD_PROJECT"] ?? env["GCP_PROJECT"] ?? env["GCLOUD_PROJECT"]
- const location = String(
- provider.options?.location ??
- env["GOOGLE_VERTEX_LOCATION"] ??
- env["GOOGLE_CLOUD_LOCATION"] ??
- env["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,
- }
- },
- options: {
- project,
- location,
- fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
- const { GoogleAuth } = await import("google-auth-library")
- 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": Effect.fnUntraced(function* () {
- const env = yield* dep.env()
- const project = env["GOOGLE_CLOUD_PROJECT"] ?? env["GCP_PROJECT"] ?? env["GCLOUD_PROJECT"]
- const location = env["GOOGLE_CLOUD_LOCATION"] ?? env["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": 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 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 {
- VERSION: GITLAB_PROVIDER_VERSION,
- isWorkflowModel,
- discoverWorkflowModels,
- } = yield* Effect.promise(() => import("gitlab-ai-provider"))
- const instanceUrl = (yield* dep.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 undefined
- })
- const token = apiKey ?? (yield* dep.get("GITLAB_TOKEN"))
- const providerConfig = (yield* dep.config()).provider?.["gitlab"]
- const directory = yield* InstanceState.directory
- const aiGatewayHeaders = {
- "User-Agent": `opencode/${InstallationVersion} 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: !!token,
- options: {
- instanceUrl,
- apiKey: token,
- aiGatewayHeaders,
- featureFlags,
- },
- async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
- if (modelID.startsWith("duo-workflow-")) {
- const workflowRef = typeof options?.workflowRef === "string" ? options.workflowRef : undefined
- // Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef
- const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow"
- const workflowDefinition =
- typeof options?.workflowDefinition === "string" ? options.workflowDefinition : undefined
- const model = sdk.workflowChat(sdkModelID, {
- featureFlags,
- workflowDefinition,
- })
- 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 {}
- }
- 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: 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",
- },
- 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,
- },
- 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": Effect.fnUntraced(function* (input: Info) {
- // When baseURL is already configured (e.g. corporate config routing through a proxy/gateway),
- // skip the account ID check because the URL is already fully specified.
- if (input.options?.baseURL) return { autoload: false }
- const auth = yield* dep.auth(input.id)
- const env = yield* dep.env()
- const accountId = env["CLOUDFLARE_ACCOUNT_ID"] || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
- if (!accountId)
- return {
- autoload: false,
- async getModel() {
- throw new Error(
- "CLOUDFLARE_ACCOUNT_ID is missing. Set it with: export CLOUDFLARE_ACCOUNT_ID=<your-account-id>",
- )
- },
- }
- const apiKey = yield* Effect.gen(function* () {
- const envToken = env["CLOUDFLARE_API_KEY"]
- if (envToken) return envToken
- if (auth?.type === "api") return auth.key
- return undefined
- })
- return {
- autoload: !!apiKey,
- options: {
- apiKey,
- headers: {
- "User-Agent": `opencode/${InstallationVersion} 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": Effect.fnUntraced(function* (input: Info) {
- // When baseURL is already configured (e.g. corporate config), skip the ID checks.
- if (input.options?.baseURL) return { autoload: false }
- const auth = yield* dep.auth(input.id)
- const env = yield* dep.env()
- const accountId = env["CLOUDFLARE_ACCOUNT_ID"] || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
- const gateway = env["CLOUDFLARE_GATEWAY_ID"] || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined)
- if (!accountId || !gateway) {
- const missing = [
- !accountId ? "CLOUDFLARE_ACCOUNT_ID" : undefined,
- !gateway ? "CLOUDFLARE_GATEWAY_ID" : undefined,
- ].filter((x): x is string => Boolean(x))
- return {
- autoload: false,
- async getModel() {
- throw new Error(
- `${missing.join(" and ")} missing. Set with: ${missing.map((x) => `export ${x}=<value>`).join(" && ")}`,
- )
- },
- }
- }
- // Get API token from env or auth - required for authenticated gateways
- const apiToken = yield* Effect.gen(function* () {
- const envToken = env["CLOUDFLARE_API_TOKEN"] || env["CF_AIG_TOKEN"]
- if (envToken) return envToken
- 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/${InstallationVersion} 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<string, any>) {
- // 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",
- },
- },
- }),
- }
- }
- const ProviderApiInfo = Schema.Struct({
- id: Schema.String,
- url: Schema.String,
- npm: Schema.String,
- })
- const ProviderModalities = Schema.Struct({
- text: Schema.Boolean,
- audio: Schema.Boolean,
- image: Schema.Boolean,
- video: Schema.Boolean,
- pdf: Schema.Boolean,
- })
- const ProviderInterleaved = Schema.Union([
- Schema.Boolean,
- Schema.Struct({
- field: Schema.Literals(["reasoning_content", "reasoning_details"]),
- }),
- ])
- const ProviderCapabilities = Schema.Struct({
- temperature: Schema.Boolean,
- reasoning: Schema.Boolean,
- attachment: Schema.Boolean,
- toolcall: Schema.Boolean,
- input: ProviderModalities,
- output: ProviderModalities,
- interleaved: ProviderInterleaved,
- })
- const ProviderCacheCost = Schema.Struct({
- read: Schema.Number,
- write: Schema.Number,
- })
- const ProviderCost = Schema.Struct({
- input: Schema.Number,
- output: Schema.Number,
- cache: ProviderCacheCost,
- experimentalOver200K: Schema.optional(
- Schema.Struct({
- input: Schema.Number,
- output: Schema.Number,
- cache: ProviderCacheCost,
- }),
- ),
- })
- const ProviderLimit = Schema.Struct({
- context: Schema.Number,
- input: Schema.optional(Schema.Number),
- output: Schema.Number,
- })
- export const Model = Schema.Struct({
- id: ModelID,
- providerID: ProviderID,
- api: ProviderApiInfo,
- name: Schema.String,
- family: Schema.optional(Schema.String),
- capabilities: ProviderCapabilities,
- cost: ProviderCost,
- limit: ProviderLimit,
- status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
- options: Schema.Record(Schema.String, Schema.Any),
- headers: Schema.Record(Schema.String, Schema.String),
- release_date: Schema.String,
- variants: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))),
- })
- .annotate({ identifier: "Model" })
- .pipe(withStatics((s) => ({ zod: zod(s) })))
- export type Model = Types.DeepMutable<Schema.Schema.Type<typeof Model>>
- export const Info = Schema.Struct({
- id: ProviderID,
- name: Schema.String,
- source: Schema.Literals(["env", "config", "custom", "api"]),
- env: Schema.Array(Schema.String),
- key: Schema.optional(Schema.String),
- options: Schema.Record(Schema.String, Schema.Any),
- models: Schema.Record(Schema.String, Model),
- })
- .annotate({ identifier: "Provider" })
- .pipe(withStatics((s) => ({ zod: zod(s) })))
- export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
- const DefaultModelIDs = Schema.Record(Schema.String, Schema.String)
- export const ListResult = Schema.Struct({
- all: Schema.Array(Info),
- default: DefaultModelIDs,
- connected: Schema.Array(Schema.String),
- }).pipe(withStatics((s) => ({ zod: zod(s) })))
- export type ListResult = Types.DeepMutable<Schema.Schema.Type<typeof ListResult>>
- export const ConfigProvidersResult = Schema.Struct({
- providers: Schema.Array(Info),
- default: DefaultModelIDs,
- }).pipe(withStatics((s) => ({ zod: zod(s) })))
- export type ConfigProvidersResult = Types.DeepMutable<Schema.Schema.Type<typeof ConfigProvidersResult>>
- export function defaultModelIDs<T extends { models: Record<string, { id: string }> }>(providers: Record<string, T>) {
- return mapValues(providers, (item) => sort(Object.values(item.models))[0].id)
- }
- export interface Interface {
- readonly list: () => Effect.Effect<Record<ProviderID, Info>>
- readonly getProvider: (providerID: ProviderID) => Effect.Effect<Info>
- readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model>
- readonly getLanguage: (model: Model) => Effect.Effect<LanguageModelV3>
- readonly closest: (
- providerID: ProviderID,
- query: string[],
- ) => Effect.Effect<{ providerID: ProviderID; modelID: string } | undefined>
- readonly getSmallModel: (providerID: ProviderID) => Effect.Effect<Model | undefined>
- readonly defaultModel: () => Effect.Effect<{ providerID: ProviderID; modelID: ModelID }>
- }
- interface State {
- models: Map<string, LanguageModelV3>
- providers: Record<ProviderID, Info>
- sdk: Map<string, BundledSDK>
- modelLoaders: Record<string, CustomModelLoader>
- varsLoaders: Record<string, CustomVarsLoader>
- }
- export class Service extends Context.Service<Service, Interface>()("@opencode/Provider") {}
- function cost(c: ModelsDev.Model["cost"]): Model["cost"] {
- const result: Model["cost"] = {
- input: c?.input ?? 0,
- output: c?.output ?? 0,
- cache: {
- read: c?.cache_read ?? 0,
- write: c?.cache_write ?? 0,
- },
- }
- if (c?.context_over_200k) {
- result.experimentalOver200K = {
- cache: {
- read: c.context_over_200k.cache_read ?? 0,
- write: c.context_over_200k.cache_write ?? 0,
- },
- input: c.context_over_200k.input,
- output: c.context_over_200k.output,
- }
- }
- return result
- }
- function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
- const base: Model = {
- id: ModelID.make(model.id),
- providerID: ProviderID.make(provider.id),
- name: model.name,
- family: model.family,
- api: {
- id: model.id,
- url: model.provider?.api ?? provider.api ?? "",
- npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
- },
- status: model.status ?? "active",
- headers: {},
- options: {},
- cost: cost(model.cost),
- limit: {
- context: model.limit.context,
- input: model.limit.input,
- output: model.limit.output,
- },
- capabilities: {
- temperature: model.temperature ?? false,
- reasoning: model.reasoning ?? false,
- attachment: model.attachment ?? false,
- toolcall: model.tool_call ?? true,
- input: {
- text: model.modalities?.input?.includes("text") ?? false,
- audio: model.modalities?.input?.includes("audio") ?? false,
- image: model.modalities?.input?.includes("image") ?? false,
- video: model.modalities?.input?.includes("video") ?? false,
- pdf: model.modalities?.input?.includes("pdf") ?? false,
- },
- output: {
- text: model.modalities?.output?.includes("text") ?? false,
- audio: model.modalities?.output?.includes("audio") ?? false,
- image: model.modalities?.output?.includes("image") ?? false,
- video: model.modalities?.output?.includes("video") ?? false,
- pdf: model.modalities?.output?.includes("pdf") ?? false,
- },
- interleaved: model.interleaved ?? false,
- },
- release_date: model.release_date ?? "",
- variants: {},
- }
- return {
- ...base,
- variants: mapValues(ProviderTransform.variants(base), (v) => v),
- }
- }
- export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
- const models: Record<string, Model> = {}
- for (const [key, model] of Object.entries(provider.models)) {
- models[key] = fromModelsDevModel(provider, model)
- for (const [mode, opts] of Object.entries(model.experimental?.modes ?? {})) {
- const id = `${model.id}-${mode}`
- const base = fromModelsDevModel(provider, model)
- models[id] = {
- ...base,
- id: ModelID.make(id),
- name: `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`,
- cost: opts.cost ? mergeDeep(base.cost, cost(opts.cost)) : base.cost,
- options: opts.provider?.body
- ? Object.fromEntries(
- Object.entries(opts.provider.body).map(([k, v]) => [
- k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()),
- v,
- ]),
- )
- : base.options,
- headers: opts.provider?.headers ?? base.headers,
- }
- }
- }
- return {
- id: ProviderID.make(provider.id),
- source: "custom",
- name: provider.name,
- env: [...(provider.env ?? [])],
- options: {},
- models,
- }
- }
- const layer: Layer.Layer<
- Service,
- never,
- Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service
- > = Layer.effect(
- Service,
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const config = yield* Config.Service
- const auth = yield* Auth.Service
- const env = yield* Env.Service
- const plugin = yield* Plugin.Service
- const state = yield* InstanceState.make<State>(() =>
- Effect.gen(function* () {
- using _ = log.time("state")
- const bridge = yield* EffectBridge.make()
- const cfg = yield* config.get()
- const modelsDev = yield* Effect.promise(() => ModelsDev.get())
- const database = mapValues(modelsDev, fromModelsDevProvider)
- const providers: Record<ProviderID, Info> = {} as Record<ProviderID, Info>
- const languages = new Map<string, LanguageModelV3>()
- const modelLoaders: {
- [providerID: string]: CustomModelLoader
- } = {}
- const varsLoaders: {
- [providerID: string]: CustomVarsLoader
- } = {}
- const sdk = new Map<string, BundledSDK>()
- const discoveryLoaders: {
- [providerID: string]: CustomDiscoverModels
- } = {}
- const dep = {
- auth: (id: string) => auth.get(id).pipe(Effect.orDie),
- config: () => config.get(),
- env: () => env.all(),
- get: (key: string) => env.get(key),
- }
- log.info("init")
- function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
- const existing = providers[providerID]
- if (existing) {
- // @ts-expect-error
- providers[providerID] = mergeDeep(existing, provider)
- return
- }
- const match = database[providerID]
- if (!match) return
- // @ts-expect-error
- 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]
- const parsed: Info = {
- id: ProviderID.make(providerID),
- name: provider.name ?? existing?.name ?? providerID,
- env: provider.env ?? existing?.env ?? [],
- options: mergeDeep(existing?.options ?? {}, provider.options ?? {}),
- source: "config",
- models: existing?.models ?? {},
- }
- for (const [modelID, model] of Object.entries(provider.models ?? {})) {
- const existingModel = parsed.models[model.id ?? modelID]
- const apiID = model.id ?? existingModel?.api.id ?? modelID
- const apiNpm =
- model.provider?.npm ??
- provider.npm ??
- existingModel?.api.npm ??
- modelsDev[providerID]?.npm ??
- "@ai-sdk/openai-compatible"
- const name = iife(() => {
- if (model.name) return model.name
- if (model.id && model.id !== modelID) return modelID
- return existingModel?.name ?? modelID
- })
- const parsedModel: Model = {
- id: ModelID.make(modelID),
- api: {
- id: apiID,
- npm: apiNpm,
- url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api ?? "",
- },
- status: model.status ?? existingModel?.status ?? "active",
- name,
- providerID: ProviderID.make(providerID),
- capabilities: {
- temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false,
- reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false,
- attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false,
- toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true,
- input: {
- text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true,
- audio: model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false,
- image: model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false,
- video: model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false,
- pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false,
- },
- output: {
- text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true,
- audio:
- model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? false,
- image:
- model.modalities?.output?.includes("image") ?? existingModel?.capabilities.output.image ?? false,
- video:
- model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false,
- pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
- },
- interleaved:
- model.interleaved ??
- existingModel?.capabilities.interleaved ??
- (!existingModel && apiNpm === "@ai-sdk/openai-compatible" && apiID.includes("deepseek")
- ? { field: "reasoning_content" }
- : false),
- },
- cost: {
- input: model?.cost?.input ?? existingModel?.cost?.input ?? 0,
- output: model?.cost?.output ?? existingModel?.cost?.output ?? 0,
- cache: {
- read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0,
- write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0,
- },
- },
- options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}),
- limit: {
- context: model.limit?.context ?? existingModel?.limit?.context ?? 0,
- input: model.limit?.input ?? existingModel?.limit?.input,
- output: model.limit?.output ?? existingModel?.limit?.output ?? 0,
- },
- headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
- family: model.family ?? existingModel?.family ?? "",
- release_date: model.release_date ?? existingModel?.release_date ?? "",
- variants: {},
- }
- const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
- parsedModel.variants = mapValues(
- pickBy(merged, (v) => !v.disabled),
- (v) => omit(v, ["disabled"]),
- )
- parsed.models[modelID] = parsedModel
- }
- database[providerID] = parsed
- }
- // load env
- const envs = yield* env.all()
- for (const [id, provider] of Object.entries(database)) {
- const providerID = ProviderID.make(id)
- if (disabled.has(providerID)) continue
- const apiKey = provider.env.map((item) => envs[item]).find(Boolean)
- if (!apiKey) continue
- mergeProvider(providerID, {
- source: "env",
- key: provider.env.length === 1 ? apiKey : undefined,
- })
- }
- // load apikeys
- const auths = yield* auth.all().pipe(Effect.orDie)
- for (const [id, provider] of Object.entries(auths)) {
- const providerID = ProviderID.make(id)
- if (disabled.has(providerID)) continue
- if (provider.type === "api") {
- mergeProvider(providerID, {
- source: "api",
- key: provider.key,
- })
- }
- }
- // 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 stored = yield* auth.get(providerID).pipe(Effect.orDie)
- if (!stored) continue
- if (!plugin.auth.loader) continue
- const options = yield* Effect.promise(() =>
- plugin.auth!.loader!(
- () => bridge.promise(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(dep))) {
- const providerID = ProviderID.make(id)
- if (disabled.has(providerID)) continue
- const data = database[providerID]
- if (!data) {
- log.error("Provider does not exist in model list " + providerID)
- continue
- }
- 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
- if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels
- const opts = result.options ?? {}
- const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
- mergeProvider(providerID, patch)
- }
- }
- // load config - re-apply with updated data
- for (const [id, provider] of configProviders) {
- const providerID = ProviderID.make(id)
- const partial: Partial<Info> = { source: "config" }
- if (provider.env) partial.env = provider.env
- if (provider.name) partial.name = provider.name
- if (provider.options) partial.options = provider.options
- mergeProvider(providerID, partial)
- }
- const gitlab = ProviderID.make("gitlab")
- if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) {
- yield* Effect.promise(async () => {
- try {
- const discovered = await discoveryLoaders[gitlab]()
- for (const [modelID, model] of Object.entries(discovered)) {
- if (!providers[gitlab].models[modelID]) {
- providers[gitlab].models[modelID] = model
- }
- }
- } catch (e) {
- log.warn("state discovery error", { id: "gitlab", error: e })
- }
- })
- }
- for (const hook of plugins) {
- const p = hook.provider
- const models = p?.models
- if (!p || !models) continue
- const providerID = ProviderID.make(p.id)
- if (disabled.has(providerID)) continue
- const provider = providers[providerID]
- if (!provider) continue
- const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
- provider.models = yield* Effect.promise(async () => {
- const next = await models(provider, { auth: pluginAuth })
- return Object.fromEntries(
- Object.entries(next).map(([id, model]) => [
- id,
- {
- ...model,
- id: ModelID.make(id),
- providerID,
- },
- ]),
- )
- })
- }
- for (const [id, provider] of Object.entries(providers)) {
- const providerID = ProviderID.make(id)
- if (!isProviderAllowed(providerID)) {
- delete providers[providerID]
- continue
- }
- const configProvider = cfg.provider?.[providerID]
- for (const [modelID, model] of Object.entries(provider.models)) {
- model.api.id = model.api.id ?? model.id ?? modelID
- if (
- modelID === "gpt-5-chat-latest" ||
- (providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat")
- )
- delete provider.models[modelID]
- if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) delete provider.models[modelID]
- if (model.status === "deprecated") delete provider.models[modelID]
- if (
- (configProvider?.blacklist && configProvider.blacklist.includes(modelID)) ||
- (configProvider?.whitelist && !configProvider.whitelist.includes(modelID))
- )
- delete provider.models[modelID]
- model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
- const configVariants = configProvider?.models?.[modelID]?.variants
- if (configVariants && model.variants) {
- const merged = mergeDeep(model.variants, configVariants)
- model.variants = mapValues(
- pickBy(merged, (v) => !v.disabled),
- (v) => omit(v, ["disabled"]),
- )
- }
- }
- if (Object.keys(provider.models).length === 0) {
- delete providers[providerID]
- continue
- }
- log.info("found", { providerID })
- }
- return {
- models: languages,
- providers,
- sdk,
- modelLoaders,
- varsLoaders,
- }
- }),
- )
- const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers))
- async function resolveSDK(model: Model, s: State, envs: Record<string, string | undefined>) {
- try {
- using _ = log.time("getSDK", {
- providerID: model.providerID,
- })
- const provider = s.providers[model.providerID]
- const options = { ...provider.options }
- if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) {
- delete options.fetch
- }
- if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) {
- options["includeUsage"] = true
- }
- const baseURL = iife(() => {
- let url =
- typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url
- if (!url) return
- const loader = s.varsLoaders[model.providerID]
- if (loader) {
- const vars = loader(options)
- for (const [key, value] of Object.entries(vars)) {
- const field = "${" + key + "}"
- url = url.replaceAll(field, value)
- }
- }
- url = url.replace(/\$\{([^}]+)\}/g, (item, key) => {
- const val = envs[String(key)]
- return val ?? item
- })
- return url
- })
- if (baseURL !== undefined) options["baseURL"] = baseURL
- if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key
- if (model.headers)
- options["headers"] = {
- ...options["headers"],
- ...model.headers,
- }
- const key = Hash.fast(
- JSON.stringify({
- providerID: model.providerID,
- npm: model.api.npm,
- options,
- }),
- )
- const existing = s.sdk.get(key)
- if (existing) return existing
- const customFetch = options["fetch"]
- const chunkTimeout = options["chunkTimeout"]
- delete options["chunkTimeout"]
- options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
- const fetchFn = customFetch ?? fetch
- const opts = init ?? {}
- const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined
- const signals: AbortSignal[] = []
- if (opts.signal) signals.push(opts.signal)
- if (chunkAbortCtl) signals.push(chunkAbortCtl.signal)
- if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false)
- signals.push(AbortSignal.timeout(options["timeout"]))
- const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals)
- if (combined) opts.signal = combined
- // Strip openai itemId metadata following what codex does
- if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") {
- const body = JSON.parse(opts.body as string)
- const isAzure = model.providerID.includes("azure")
- const keepIds = isAzure && body.store === true
- if (!keepIds && Array.isArray(body.input)) {
- for (const item of body.input) {
- if ("id" in item) {
- delete item.id
- }
- }
- opts.body = JSON.stringify(body)
- }
- }
- const res = await fetchFn(input, {
- ...opts,
- // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
- timeout: false,
- })
- if (!chunkAbortCtl) return res
- return wrapSSE(res, chunkTimeout, chunkAbortCtl)
- }
- const bundledLoader = BUNDLED_PROVIDERS[model.api.npm]
- if (bundledLoader) {
- log.info("using bundled provider", {
- providerID: model.providerID,
- pkg: model.api.npm,
- })
- const factory = await bundledLoader()
- const loaded = factory({
- name: model.providerID,
- ...options,
- })
- s.sdk.set(key, loaded)
- return loaded as SDK
- }
- let installedPath: string
- if (!model.api.npm.startsWith("file://")) {
- const item = await Npm.add(model.api.npm)
- if (!item.entrypoint) throw new Error(`Package ${model.api.npm} has no import entrypoint`)
- installedPath = item.entrypoint
- } else {
- log.info("loading local provider", { pkg: model.api.npm })
- installedPath = model.api.npm
- }
- // `installedPath` is a local entry path or an existing `file://` URL. Normalize
- // only path inputs so Node on Windows accepts the dynamic import.
- const importSpec = installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href
- const mod = await import(importSpec)
- const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
- const loaded = fn({
- name: model.providerID,
- ...options,
- })
- s.sdk.set(key, loaded)
- return loaded as SDK
- } catch (e) {
- throw new InitError({ providerID: model.providerID }, { cause: e })
- }
- }
- const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) =>
- InstanceState.use(state, (s) => s.providers[providerID]),
- )
- const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) {
- const s = yield* InstanceState.get(state)
- const provider = s.providers[providerID]
- if (!provider) {
- const available = Object.keys(s.providers)
- const matches = fuzzysort.go(providerID, available, { limit: 3, threshold: -10000 })
- throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) })
- }
- const info = provider.models[modelID]
- if (!info) {
- const available = Object.keys(provider.models)
- const matches = fuzzysort.go(modelID, available, { limit: 3, threshold: -10000 })
- throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) })
- }
- return info
- })
- const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) {
- const s = yield* InstanceState.get(state)
- const envs = yield* env.all()
- const key = `${model.providerID}/${model.id}`
- if (s.models.has(key)) return s.models.get(key)!
- return yield* Effect.promise(async () => {
- const provider = s.providers[model.providerID]
- const sdk = await resolveSDK(model, s, envs)
- try {
- const language = s.modelLoaders[model.providerID]
- ? await s.modelLoaders[model.providerID](sdk, model.api.id, {
- ...provider.options,
- ...model.options,
- })
- : sdk.languageModel(model.api.id)
- s.models.set(key, language)
- return language
- } catch (e) {
- if (e instanceof NoSuchModelError)
- throw new ModelNotFoundError(
- {
- modelID: model.id,
- providerID: model.providerID,
- },
- { cause: e },
- )
- throw e
- }
- })
- })
- const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
- const s = yield* InstanceState.get(state)
- const provider = s.providers[providerID]
- if (!provider) return undefined
- for (const item of query) {
- for (const modelID of Object.keys(provider.models)) {
- if (modelID.includes(item)) return { providerID, modelID }
- }
- }
- return undefined
- })
- const getSmallModel = Effect.fn("Provider.getSmallModel")(function* (providerID: ProviderID) {
- const cfg = yield* config.get()
- if (cfg.small_model) {
- const parsed = parseModel(cfg.small_model)
- return yield* getModel(parsed.providerID, parsed.modelID)
- }
- const s = yield* InstanceState.get(state)
- const provider = s.providers[providerID]
- if (!provider) return undefined
- let priority = [
- "claude-haiku-4-5",
- "claude-haiku-4.5",
- "3-5-haiku",
- "3.5-haiku",
- "gemini-3-flash",
- "gemini-2.5-flash",
- "gpt-5-nano",
- ]
- if (providerID.startsWith("opencode")) {
- priority = ["gpt-5-nano"]
- }
- if (providerID.startsWith("github-copilot")) {
- priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority]
- }
- for (const item of priority) {
- if (providerID === ProviderID.amazonBedrock) {
- const crossRegionPrefixes = ["global.", "us.", "eu."]
- const candidates = Object.keys(provider.models).filter((m) => m.includes(item))
- const globalMatch = candidates.find((m) => m.startsWith("global."))
- if (globalMatch) return yield* getModel(providerID, ModelID.make(globalMatch))
- const region = provider.options?.region
- if (region) {
- const regionPrefix = region.split("-")[0]
- if (regionPrefix === "us" || regionPrefix === "eu") {
- const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`))
- if (regionalMatch) return yield* getModel(providerID, ModelID.make(regionalMatch))
- }
- }
- const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p)))
- if (unprefixed) return yield* getModel(providerID, ModelID.make(unprefixed))
- } else {
- for (const model of Object.keys(provider.models)) {
- if (model.includes(item)) return yield* getModel(providerID, ModelID.make(model))
- }
- }
- }
- return undefined
- })
- const defaultModel = Effect.fn("Provider.defaultModel")(function* () {
- const cfg = yield* config.get()
- if (cfg.model) return parseModel(cfg.model)
- const s = yield* InstanceState.get(state)
- const recent = yield* fs.readJson(path.join(Global.Path.state, "model.json")).pipe(
- Effect.map((x): { providerID: ProviderID; modelID: ModelID }[] => {
- if (!isRecord(x) || !Array.isArray(x.recent)) return []
- return x.recent.flatMap((item) => {
- if (!isRecord(item)) return []
- if (typeof item.providerID !== "string") return []
- if (typeof item.modelID !== "string") return []
- return [{ providerID: ProviderID.make(item.providerID), modelID: ModelID.make(item.modelID) }]
- })
- }),
- Effect.catch(() => Effect.succeed([] as { providerID: ProviderID; modelID: ModelID }[])),
- )
- for (const entry of recent) {
- const provider = s.providers[entry.providerID]
- if (!provider) continue
- if (!provider.models[entry.modelID]) continue
- return { providerID: entry.providerID, modelID: entry.modelID }
- }
- const provider = Object.values(s.providers).find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id))
- if (!provider) throw new Error("no providers found")
- const [model] = sort(Object.values(provider.models))
- if (!model) throw new Error("no models found")
- return {
- providerID: provider.id,
- modelID: model.id,
- }
- })
- return Service.of({ list, getProvider, getModel, getLanguage, closest, getSmallModel, defaultModel })
- }),
- )
- export const defaultLayer = Layer.suspend(() =>
- layer.pipe(
- Layer.provide(AppFileSystem.defaultLayer),
- Layer.provide(Env.defaultLayer),
- Layer.provide(Config.defaultLayer),
- Layer.provide(Auth.defaultLayer),
- Layer.provide(Plugin.defaultLayer),
- ),
- )
- const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
- export function sort<T extends { id: string }>(models: T[]) {
- return sortBy(
- models,
- [(model) => priority.findIndex((filter) => model.id.includes(filter)), "desc"],
- [(model) => (model.id.includes("latest") ? 0 : 1), "asc"],
- [(model) => model.id, "desc"],
- )
- }
- export function parseModel(model: string) {
- const [providerID, ...rest] = model.split("/")
- return {
- providerID: ProviderID.make(providerID),
- modelID: ModelID.make(rest.join("/")),
- }
- }
- export const ModelNotFoundError = namedSchemaError("ProviderModelNotFoundError", {
- providerID: ProviderID,
- modelID: ModelID,
- suggestions: Schema.optional(Schema.Array(Schema.String)),
- })
- export const InitError = namedSchemaError("ProviderInitError", {
- providerID: ProviderID,
- })
|