provider.ts 63 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730
  1. import os from "os"
  2. import fuzzysort from "fuzzysort"
  3. import { Config } from "../config"
  4. import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
  5. import { NoSuchModelError, type Provider as SDK } from "ai"
  6. import { Log } from "../util"
  7. import { Npm } from "@opencode-ai/core/npm"
  8. import { Hash } from "@opencode-ai/core/util/hash"
  9. import { Plugin } from "../plugin"
  10. import { type LanguageModelV3 } from "@ai-sdk/provider"
  11. import * as ModelsDev from "./models"
  12. import { Auth } from "../auth"
  13. import { Env } from "../env"
  14. import { InstallationVersion } from "@opencode-ai/core/installation/version"
  15. import { Flag } from "@opencode-ai/core/flag/flag"
  16. import { zod } from "@/util/effect-zod"
  17. import { namedSchemaError } from "@/util/named-schema-error"
  18. import { iife } from "@/util/iife"
  19. import { Global } from "@opencode-ai/core/global"
  20. import path from "path"
  21. import { pathToFileURL } from "url"
  22. import { Effect, Layer, Context, Schema, Types } from "effect"
  23. import { EffectBridge } from "@/effect"
  24. import { InstanceState } from "@/effect"
  25. import { AppFileSystem } from "@opencode-ai/core/filesystem"
  26. import { isRecord } from "@/util/record"
  27. import { withStatics } from "@/util/schema"
  28. import * as ProviderTransform from "./transform"
  29. import { ModelID, ProviderID } from "./schema"
  30. const log = Log.create({ service: "provider" })
  31. function shouldUseCopilotResponsesApi(modelID: string): boolean {
  32. const match = /^gpt-(\d+)/.exec(modelID)
  33. if (!match) return false
  34. return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
  35. }
  36. function wrapSSE(res: Response, ms: number, ctl: AbortController) {
  37. if (typeof ms !== "number" || ms <= 0) return res
  38. if (!res.body) return res
  39. if (!res.headers.get("content-type")?.includes("text/event-stream")) return res
  40. const reader = res.body.getReader()
  41. const body = new ReadableStream<Uint8Array>({
  42. async pull(ctrl) {
  43. const part = await new Promise<Awaited<ReturnType<typeof reader.read>>>((resolve, reject) => {
  44. const id = setTimeout(() => {
  45. const err = new Error("SSE read timed out")
  46. ctl.abort(err)
  47. void reader.cancel(err)
  48. reject(err)
  49. }, ms)
  50. reader.read().then(
  51. (part) => {
  52. clearTimeout(id)
  53. resolve(part)
  54. },
  55. (err) => {
  56. clearTimeout(id)
  57. reject(err)
  58. },
  59. )
  60. })
  61. if (part.done) {
  62. ctrl.close()
  63. return
  64. }
  65. ctrl.enqueue(part.value)
  66. },
  67. async cancel(reason) {
  68. ctl.abort(reason)
  69. await reader.cancel(reason)
  70. },
  71. })
  72. return new Response(body, {
  73. headers: new Headers(res.headers),
  74. status: res.status,
  75. statusText: res.statusText,
  76. })
  77. }
  78. type BundledSDK = {
  79. languageModel(modelId: string): LanguageModelV3
  80. }
  81. const BUNDLED_PROVIDERS: Record<string, () => Promise<(opts: any) => BundledSDK>> = {
  82. "@ai-sdk/amazon-bedrock": () => import("@ai-sdk/amazon-bedrock").then((m) => m.createAmazonBedrock),
  83. "@ai-sdk/anthropic": () => import("@ai-sdk/anthropic").then((m) => m.createAnthropic),
  84. "@ai-sdk/azure": () => import("@ai-sdk/azure").then((m) => m.createAzure),
  85. "@ai-sdk/google": () => import("@ai-sdk/google").then((m) => m.createGoogleGenerativeAI),
  86. "@ai-sdk/google-vertex": () => import("@ai-sdk/google-vertex").then((m) => m.createVertex),
  87. "@ai-sdk/google-vertex/anthropic": () =>
  88. import("@ai-sdk/google-vertex/anthropic").then((m) => m.createVertexAnthropic),
  89. "@ai-sdk/openai": () => import("@ai-sdk/openai").then((m) => m.createOpenAI),
  90. "@ai-sdk/openai-compatible": () => import("@ai-sdk/openai-compatible").then((m) => m.createOpenAICompatible),
  91. "@openrouter/ai-sdk-provider": () => import("@openrouter/ai-sdk-provider").then((m) => m.createOpenRouter),
  92. "@ai-sdk/xai": () => import("@ai-sdk/xai").then((m) => m.createXai),
  93. "@ai-sdk/mistral": () => import("@ai-sdk/mistral").then((m) => m.createMistral),
  94. "@ai-sdk/groq": () => import("@ai-sdk/groq").then((m) => m.createGroq),
  95. "@ai-sdk/deepinfra": () => import("@ai-sdk/deepinfra").then((m) => m.createDeepInfra),
  96. "@ai-sdk/cerebras": () => import("@ai-sdk/cerebras").then((m) => m.createCerebras),
  97. "@ai-sdk/cohere": () => import("@ai-sdk/cohere").then((m) => m.createCohere),
  98. "@ai-sdk/gateway": () => import("@ai-sdk/gateway").then((m) => m.createGateway),
  99. "@ai-sdk/togetherai": () => import("@ai-sdk/togetherai").then((m) => m.createTogetherAI),
  100. "@ai-sdk/perplexity": () => import("@ai-sdk/perplexity").then((m) => m.createPerplexity),
  101. "@ai-sdk/vercel": () => import("@ai-sdk/vercel").then((m) => m.createVercel),
  102. "@ai-sdk/alibaba": () => import("@ai-sdk/alibaba").then((m) => m.createAlibaba),
  103. "gitlab-ai-provider": () => import("gitlab-ai-provider").then((m) => m.createGitLab),
  104. "@ai-sdk/github-copilot": () => import("./sdk/copilot").then((m) => m.createOpenaiCompatible),
  105. "venice-ai-sdk-provider": () => import("venice-ai-sdk-provider").then((m) => m.createVenice),
  106. }
  107. type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
  108. type CustomVarsLoader = (options: Record<string, any>) => Record<string, string>
  109. type CustomDiscoverModels = () => Promise<Record<string, Model>>
  110. type CustomLoader = (provider: Info) => Effect.Effect<{
  111. autoload: boolean
  112. getModel?: CustomModelLoader
  113. vars?: CustomVarsLoader
  114. options?: Record<string, any>
  115. discoverModels?: CustomDiscoverModels
  116. }>
  117. type CustomDep = {
  118. auth: (id: string) => Effect.Effect<Auth.Info | undefined>
  119. config: () => Effect.Effect<Config.Info>
  120. env: () => Effect.Effect<Record<string, string | undefined>>
  121. get: (key: string) => Effect.Effect<string | undefined>
  122. }
  123. function useLanguageModel(sdk: any) {
  124. return sdk.responses === undefined && sdk.chat === undefined
  125. }
  126. function custom(dep: CustomDep): Record<string, CustomLoader> {
  127. return {
  128. anthropic: () =>
  129. Effect.succeed({
  130. autoload: false,
  131. options: {
  132. headers: {
  133. "anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
  134. },
  135. },
  136. }),
  137. opencode: Effect.fnUntraced(function* (input: Info) {
  138. const env = yield* dep.env()
  139. const hasKey = iife(() => {
  140. if (input.env.some((item) => env[item])) return true
  141. return false
  142. })
  143. const ok =
  144. hasKey ||
  145. Boolean(yield* dep.auth(input.id)) ||
  146. Boolean((yield* dep.config()).provider?.["opencode"]?.options?.apiKey)
  147. if (!ok) {
  148. for (const [key, value] of Object.entries(input.models)) {
  149. if (value.cost.input === 0) continue
  150. delete input.models[key]
  151. }
  152. }
  153. return {
  154. autoload: Object.keys(input.models).length > 0,
  155. options: ok ? {} : { apiKey: "public" },
  156. }
  157. }),
  158. openai: () =>
  159. Effect.succeed({
  160. autoload: false,
  161. async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
  162. return sdk.responses(modelID)
  163. },
  164. options: {},
  165. }),
  166. xai: () =>
  167. Effect.succeed({
  168. autoload: false,
  169. async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
  170. return sdk.responses(modelID)
  171. },
  172. options: {},
  173. }),
  174. "github-copilot": () =>
  175. Effect.succeed({
  176. autoload: false,
  177. async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
  178. if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
  179. return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
  180. },
  181. options: {},
  182. }),
  183. azure: Effect.fnUntraced(function* (provider: Info) {
  184. const env = yield* dep.env()
  185. const resource = iife(() => {
  186. const name = provider.options?.resourceName
  187. if (typeof name === "string" && name.trim() !== "") return name
  188. return env["AZURE_RESOURCE_NAME"]
  189. })
  190. return {
  191. autoload: false,
  192. async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
  193. if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
  194. if (options?.["useCompletionUrls"]) {
  195. return sdk.chat(modelID)
  196. } else {
  197. return sdk.responses(modelID)
  198. }
  199. },
  200. options: {},
  201. vars(_options) {
  202. return {
  203. ...(resource && { AZURE_RESOURCE_NAME: resource }),
  204. }
  205. },
  206. }
  207. }),
  208. "azure-cognitive-services": Effect.fnUntraced(function* () {
  209. const resourceName = yield* dep.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
  210. return {
  211. autoload: false,
  212. async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
  213. if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
  214. if (options?.["useCompletionUrls"]) {
  215. return sdk.chat(modelID)
  216. } else {
  217. return sdk.responses(modelID)
  218. }
  219. },
  220. options: {
  221. baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
  222. },
  223. }
  224. }),
  225. "amazon-bedrock": Effect.fnUntraced(function* () {
  226. const providerConfig = (yield* dep.config()).provider?.["amazon-bedrock"]
  227. const auth = yield* dep.auth("amazon-bedrock")
  228. const env = yield* dep.env()
  229. // Region precedence: 1) config file, 2) env var, 3) default
  230. const configRegion = providerConfig?.options?.region
  231. const envRegion = env["AWS_REGION"]
  232. const defaultRegion = configRegion ?? envRegion ?? "us-east-1"
  233. // Profile: config file takes precedence over env var
  234. const configProfile = providerConfig?.options?.profile
  235. const envProfile = env["AWS_PROFILE"]
  236. const profile = configProfile ?? envProfile
  237. const awsAccessKeyId = env["AWS_ACCESS_KEY_ID"]
  238. // TODO: Using process.env directly because Env.set only updates a process.env shallow copy,
  239. // until the scope of the Env API is clarified (test only or runtime?)
  240. const awsBearerToken = iife(() => {
  241. const envToken = process.env.AWS_BEARER_TOKEN_BEDROCK
  242. if (envToken) return envToken
  243. if (auth?.type === "api") {
  244. process.env.AWS_BEARER_TOKEN_BEDROCK = auth.key
  245. return auth.key
  246. }
  247. return undefined
  248. })
  249. const awsWebIdentityTokenFile = env["AWS_WEB_IDENTITY_TOKEN_FILE"]
  250. const containerCreds = Boolean(
  251. process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
  252. )
  253. if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile && !containerCreds)
  254. return { autoload: false }
  255. const { fromNodeProviderChain } = yield* Effect.promise(() => import("@aws-sdk/credential-providers"))
  256. const providerOptions: Record<string, any> = {
  257. region: defaultRegion,
  258. }
  259. // Only use credential chain if no bearer token exists
  260. // Bearer token takes precedence over credential chain (profiles, access keys, IAM roles, web identity tokens)
  261. if (!awsBearerToken) {
  262. // Build credential provider options (only pass profile if specified)
  263. const credentialProviderOptions = profile ? { profile } : {}
  264. providerOptions.credentialProvider = fromNodeProviderChain(credentialProviderOptions)
  265. }
  266. // Add custom endpoint if specified (endpoint takes precedence over baseURL)
  267. const endpoint = providerConfig?.options?.endpoint ?? providerConfig?.options?.baseURL
  268. if (endpoint) {
  269. providerOptions.baseURL = endpoint
  270. }
  271. return {
  272. autoload: true,
  273. options: providerOptions,
  274. async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
  275. // Skip region prefixing if model already has a cross-region inference profile prefix
  276. // Models from models.dev may already include prefixes like us., eu., global., etc.
  277. const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
  278. if (crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))) {
  279. return sdk.languageModel(modelID)
  280. }
  281. // Region resolution precedence (highest to lowest):
  282. // 1. options.region from opencode.json provider config
  283. // 2. defaultRegion from AWS_REGION environment variable
  284. // 3. Default "us-east-1" (baked into defaultRegion)
  285. const region = options?.region ?? defaultRegion
  286. let regionPrefix = region.split("-")[0]
  287. switch (regionPrefix) {
  288. case "us": {
  289. const modelRequiresPrefix = [
  290. "nova-micro",
  291. "nova-lite",
  292. "nova-pro",
  293. "nova-premier",
  294. "nova-2",
  295. "claude",
  296. "deepseek",
  297. ].some((m) => modelID.includes(m))
  298. const isGovCloud = region.startsWith("us-gov")
  299. if (modelRequiresPrefix && !isGovCloud) {
  300. modelID = `${regionPrefix}.${modelID}`
  301. }
  302. break
  303. }
  304. case "eu": {
  305. const regionRequiresPrefix = [
  306. "eu-west-1",
  307. "eu-west-2",
  308. "eu-west-3",
  309. "eu-north-1",
  310. "eu-central-1",
  311. "eu-south-1",
  312. "eu-south-2",
  313. ].some((r) => region.includes(r))
  314. const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((m) =>
  315. modelID.includes(m),
  316. )
  317. if (regionRequiresPrefix && modelRequiresPrefix) {
  318. modelID = `${regionPrefix}.${modelID}`
  319. }
  320. break
  321. }
  322. case "ap": {
  323. const isAustraliaRegion = ["ap-southeast-2", "ap-southeast-4"].includes(region)
  324. const isTokyoRegion = region === "ap-northeast-1"
  325. if (
  326. isAustraliaRegion &&
  327. ["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((m) => modelID.includes(m))
  328. ) {
  329. regionPrefix = "au"
  330. modelID = `${regionPrefix}.${modelID}`
  331. } else if (isTokyoRegion) {
  332. // Tokyo region uses jp. prefix for cross-region inference
  333. const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) =>
  334. modelID.includes(m),
  335. )
  336. if (modelRequiresPrefix) {
  337. regionPrefix = "jp"
  338. modelID = `${regionPrefix}.${modelID}`
  339. }
  340. } else {
  341. // Other APAC regions use apac. prefix
  342. const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) =>
  343. modelID.includes(m),
  344. )
  345. if (modelRequiresPrefix) {
  346. regionPrefix = "apac"
  347. modelID = `${regionPrefix}.${modelID}`
  348. }
  349. }
  350. break
  351. }
  352. }
  353. return sdk.languageModel(modelID)
  354. },
  355. }
  356. }),
  357. llmgateway: () =>
  358. Effect.succeed({
  359. autoload: false,
  360. options: {
  361. headers: {
  362. "HTTP-Referer": "https://opencode.ai/",
  363. "X-Title": "opencode",
  364. "X-Source": "opencode",
  365. },
  366. },
  367. }),
  368. openrouter: () =>
  369. Effect.succeed({
  370. autoload: false,
  371. options: {
  372. headers: {
  373. "HTTP-Referer": "https://opencode.ai/",
  374. "X-Title": "opencode",
  375. },
  376. },
  377. }),
  378. nvidia: () =>
  379. Effect.succeed({
  380. autoload: false,
  381. options: {
  382. headers: {
  383. "HTTP-Referer": "https://opencode.ai/",
  384. "X-Title": "opencode",
  385. },
  386. },
  387. }),
  388. vercel: () =>
  389. Effect.succeed({
  390. autoload: false,
  391. options: {
  392. headers: {
  393. "http-referer": "https://opencode.ai/",
  394. "x-title": "opencode",
  395. },
  396. },
  397. }),
  398. "google-vertex": Effect.fnUntraced(function* (provider: Info) {
  399. const env = yield* dep.env()
  400. const project =
  401. provider.options?.project ?? env["GOOGLE_CLOUD_PROJECT"] ?? env["GCP_PROJECT"] ?? env["GCLOUD_PROJECT"]
  402. const location = String(
  403. provider.options?.location ??
  404. env["GOOGLE_VERTEX_LOCATION"] ??
  405. env["GOOGLE_CLOUD_LOCATION"] ??
  406. env["VERTEX_LOCATION"] ??
  407. "us-central1",
  408. )
  409. const autoload = Boolean(project)
  410. if (!autoload) return { autoload: false }
  411. return {
  412. autoload: true,
  413. vars(_options: Record<string, any>) {
  414. const endpoint = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`
  415. return {
  416. ...(project && { GOOGLE_VERTEX_PROJECT: project }),
  417. GOOGLE_VERTEX_LOCATION: location,
  418. GOOGLE_VERTEX_ENDPOINT: endpoint,
  419. }
  420. },
  421. options: {
  422. project,
  423. location,
  424. fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
  425. const { GoogleAuth } = await import("google-auth-library")
  426. const auth = new GoogleAuth()
  427. const client = await auth.getApplicationDefault()
  428. const token = await client.credential.getAccessToken()
  429. const headers = new Headers(init?.headers)
  430. headers.set("Authorization", `Bearer ${token.token}`)
  431. return fetch(input, { ...init, headers })
  432. },
  433. },
  434. async getModel(sdk: any, modelID: string) {
  435. const id = String(modelID).trim()
  436. return sdk.languageModel(id)
  437. },
  438. }
  439. }),
  440. "google-vertex-anthropic": Effect.fnUntraced(function* () {
  441. const env = yield* dep.env()
  442. const project = env["GOOGLE_CLOUD_PROJECT"] ?? env["GCP_PROJECT"] ?? env["GCLOUD_PROJECT"]
  443. const location = env["GOOGLE_CLOUD_LOCATION"] ?? env["VERTEX_LOCATION"] ?? "global"
  444. const autoload = Boolean(project)
  445. if (!autoload) return { autoload: false }
  446. return {
  447. autoload: true,
  448. options: {
  449. project,
  450. location,
  451. },
  452. async getModel(sdk: any, modelID) {
  453. const id = String(modelID).trim()
  454. return sdk.languageModel(id)
  455. },
  456. }
  457. }),
  458. "sap-ai-core": Effect.fnUntraced(function* () {
  459. const auth = yield* dep.auth("sap-ai-core")
  460. // TODO: Using process.env directly because Env.set only updates a shallow copy (not process.env),
  461. // until the scope of the Env API is clarified (test only or runtime?)
  462. const envServiceKey = iife(() => {
  463. const envAICoreServiceKey = process.env.AICORE_SERVICE_KEY
  464. if (envAICoreServiceKey) return envAICoreServiceKey
  465. if (auth?.type === "api") {
  466. process.env.AICORE_SERVICE_KEY = auth.key
  467. return auth.key
  468. }
  469. return undefined
  470. })
  471. const deploymentId = process.env.AICORE_DEPLOYMENT_ID
  472. const resourceGroup = process.env.AICORE_RESOURCE_GROUP
  473. return {
  474. autoload: !!envServiceKey,
  475. options: envServiceKey ? { deploymentId, resourceGroup } : {},
  476. async getModel(sdk: any, modelID: string) {
  477. return sdk(modelID)
  478. },
  479. }
  480. }),
  481. zenmux: () =>
  482. Effect.succeed({
  483. autoload: false,
  484. options: {
  485. headers: {
  486. "HTTP-Referer": "https://opencode.ai/",
  487. "X-Title": "opencode",
  488. },
  489. },
  490. }),
  491. gitlab: Effect.fnUntraced(function* (input: Info) {
  492. const {
  493. VERSION: GITLAB_PROVIDER_VERSION,
  494. isWorkflowModel,
  495. discoverWorkflowModels,
  496. } = yield* Effect.promise(() => import("gitlab-ai-provider"))
  497. const instanceUrl = (yield* dep.get("GITLAB_INSTANCE_URL")) || "https://gitlab.com"
  498. const auth = yield* dep.auth(input.id)
  499. const apiKey = yield* Effect.sync(() => {
  500. if (auth?.type === "oauth") return auth.access
  501. if (auth?.type === "api") return auth.key
  502. return undefined
  503. })
  504. const token = apiKey ?? (yield* dep.get("GITLAB_TOKEN"))
  505. const providerConfig = (yield* dep.config()).provider?.["gitlab"]
  506. const directory = yield* InstanceState.directory
  507. const aiGatewayHeaders = {
  508. "User-Agent": `opencode/${InstallationVersion} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
  509. "anthropic-beta": "context-1m-2025-08-07",
  510. ...providerConfig?.options?.aiGatewayHeaders,
  511. }
  512. const featureFlags = {
  513. duo_agent_platform_agentic_chat: true,
  514. duo_agent_platform: true,
  515. ...providerConfig?.options?.featureFlags,
  516. }
  517. return {
  518. autoload: !!token,
  519. options: {
  520. instanceUrl,
  521. apiKey: token,
  522. aiGatewayHeaders,
  523. featureFlags,
  524. },
  525. async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
  526. if (modelID.startsWith("duo-workflow-")) {
  527. const workflowRef = typeof options?.workflowRef === "string" ? options.workflowRef : undefined
  528. // Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef
  529. const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow"
  530. const workflowDefinition =
  531. typeof options?.workflowDefinition === "string" ? options.workflowDefinition : undefined
  532. const model = sdk.workflowChat(sdkModelID, {
  533. featureFlags,
  534. workflowDefinition,
  535. })
  536. if (workflowRef) {
  537. model.selectedModelRef = workflowRef
  538. }
  539. return model
  540. }
  541. return sdk.agenticChat(modelID, {
  542. aiGatewayHeaders,
  543. featureFlags,
  544. })
  545. },
  546. async discoverModels(): Promise<Record<string, Model>> {
  547. if (!apiKey) {
  548. log.info("gitlab model discovery skipped: no apiKey")
  549. return {}
  550. }
  551. try {
  552. const token = apiKey
  553. const getHeaders = (): Record<string, string> =>
  554. auth?.type === "api" ? { "PRIVATE-TOKEN": token } : { Authorization: `Bearer ${token}` }
  555. log.info("gitlab model discovery starting", { instanceUrl })
  556. const result = await discoverWorkflowModels({ instanceUrl, getHeaders }, { workingDirectory: directory })
  557. if (!result.models.length) {
  558. log.info("gitlab model discovery skipped: no models found", {
  559. project: result.project
  560. ? {
  561. id: result.project.id,
  562. path: result.project.pathWithNamespace,
  563. }
  564. : null,
  565. })
  566. return {}
  567. }
  568. const models: Record<string, Model> = {}
  569. for (const m of result.models) {
  570. if (!input.models[m.id]) {
  571. models[m.id] = {
  572. id: ModelID.make(m.id),
  573. providerID: ProviderID.make("gitlab"),
  574. name: `Agent Platform (${m.name})`,
  575. family: "",
  576. api: {
  577. id: m.id,
  578. url: instanceUrl,
  579. npm: "gitlab-ai-provider",
  580. },
  581. status: "active",
  582. headers: {},
  583. options: { workflowRef: m.ref },
  584. cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
  585. limit: { context: m.context, output: m.output },
  586. capabilities: {
  587. temperature: false,
  588. reasoning: true,
  589. attachment: true,
  590. toolcall: true,
  591. input: {
  592. text: true,
  593. audio: false,
  594. image: true,
  595. video: false,
  596. pdf: true,
  597. },
  598. output: {
  599. text: true,
  600. audio: false,
  601. image: false,
  602. video: false,
  603. pdf: false,
  604. },
  605. interleaved: false,
  606. },
  607. release_date: "",
  608. variants: {},
  609. }
  610. }
  611. }
  612. log.info("gitlab model discovery complete", {
  613. count: Object.keys(models).length,
  614. models: Object.keys(models),
  615. })
  616. return models
  617. } catch (e) {
  618. log.warn("gitlab model discovery failed", { error: e })
  619. return {}
  620. }
  621. },
  622. }
  623. }),
  624. "cloudflare-workers-ai": Effect.fnUntraced(function* (input: Info) {
  625. // When baseURL is already configured (e.g. corporate config routing through a proxy/gateway),
  626. // skip the account ID check because the URL is already fully specified.
  627. if (input.options?.baseURL) return { autoload: false }
  628. const auth = yield* dep.auth(input.id)
  629. const env = yield* dep.env()
  630. const accountId = env["CLOUDFLARE_ACCOUNT_ID"] || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
  631. if (!accountId)
  632. return {
  633. autoload: false,
  634. async getModel() {
  635. throw new Error(
  636. "CLOUDFLARE_ACCOUNT_ID is missing. Set it with: export CLOUDFLARE_ACCOUNT_ID=<your-account-id>",
  637. )
  638. },
  639. }
  640. const apiKey = yield* Effect.gen(function* () {
  641. const envToken = env["CLOUDFLARE_API_KEY"]
  642. if (envToken) return envToken
  643. if (auth?.type === "api") return auth.key
  644. return undefined
  645. })
  646. return {
  647. autoload: !!apiKey,
  648. options: {
  649. apiKey,
  650. headers: {
  651. "User-Agent": `opencode/${InstallationVersion} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
  652. },
  653. },
  654. async getModel(sdk: any, modelID: string) {
  655. return sdk.languageModel(modelID)
  656. },
  657. vars(_options) {
  658. return {
  659. CLOUDFLARE_ACCOUNT_ID: accountId,
  660. }
  661. },
  662. }
  663. }),
  664. "cloudflare-ai-gateway": Effect.fnUntraced(function* (input: Info) {
  665. // When baseURL is already configured (e.g. corporate config), skip the ID checks.
  666. if (input.options?.baseURL) return { autoload: false }
  667. const auth = yield* dep.auth(input.id)
  668. const env = yield* dep.env()
  669. const accountId = env["CLOUDFLARE_ACCOUNT_ID"] || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
  670. const gateway = env["CLOUDFLARE_GATEWAY_ID"] || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined)
  671. if (!accountId || !gateway) {
  672. const missing = [
  673. !accountId ? "CLOUDFLARE_ACCOUNT_ID" : undefined,
  674. !gateway ? "CLOUDFLARE_GATEWAY_ID" : undefined,
  675. ].filter((x): x is string => Boolean(x))
  676. return {
  677. autoload: false,
  678. async getModel() {
  679. throw new Error(
  680. `${missing.join(" and ")} missing. Set with: ${missing.map((x) => `export ${x}=<value>`).join(" && ")}`,
  681. )
  682. },
  683. }
  684. }
  685. // Get API token from env or auth - required for authenticated gateways
  686. const apiToken = yield* Effect.gen(function* () {
  687. const envToken = env["CLOUDFLARE_API_TOKEN"] || env["CF_AIG_TOKEN"]
  688. if (envToken) return envToken
  689. if (auth?.type === "api") return auth.key
  690. return undefined
  691. })
  692. if (!apiToken) {
  693. throw new Error(
  694. "CLOUDFLARE_API_TOKEN (or CF_AIG_TOKEN) is required for Cloudflare AI Gateway. " +
  695. "Set it via environment variable or run `opencode auth cloudflare-ai-gateway`.",
  696. )
  697. }
  698. // Use official ai-gateway-provider package (v2.x for AI SDK v5 compatibility)
  699. const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider"))
  700. const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified"))
  701. const metadata = iife(() => {
  702. if (input.options?.metadata) return input.options.metadata
  703. try {
  704. return JSON.parse(input.options?.headers?.["cf-aig-metadata"])
  705. } catch {
  706. return undefined
  707. }
  708. })
  709. const opts = {
  710. metadata,
  711. cacheTtl: input.options?.cacheTtl,
  712. cacheKey: input.options?.cacheKey,
  713. skipCache: input.options?.skipCache,
  714. collectLog: input.options?.collectLog,
  715. headers: {
  716. "User-Agent": `opencode/${InstallationVersion} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`,
  717. },
  718. }
  719. const aigateway = createAiGateway({
  720. accountId,
  721. gateway,
  722. apiKey: apiToken,
  723. ...(Object.values(opts).some((v) => v !== undefined) ? { options: opts } : {}),
  724. })
  725. const unified = createUnified()
  726. return {
  727. autoload: true,
  728. async getModel(_sdk: any, modelID: string, _options?: Record<string, any>) {
  729. // Model IDs use Unified API format: provider/model (e.g., "anthropic/claude-sonnet-4-5")
  730. return aigateway(unified(modelID))
  731. },
  732. options: {},
  733. }
  734. }),
  735. cerebras: () =>
  736. Effect.succeed({
  737. autoload: false,
  738. options: {
  739. headers: {
  740. "X-Cerebras-3rd-Party-Integration": "opencode",
  741. },
  742. },
  743. }),
  744. kilo: () =>
  745. Effect.succeed({
  746. autoload: false,
  747. options: {
  748. headers: {
  749. "HTTP-Referer": "https://opencode.ai/",
  750. "X-Title": "opencode",
  751. },
  752. },
  753. }),
  754. }
  755. }
  756. const ProviderApiInfo = Schema.Struct({
  757. id: Schema.String,
  758. url: Schema.String,
  759. npm: Schema.String,
  760. })
  761. const ProviderModalities = Schema.Struct({
  762. text: Schema.Boolean,
  763. audio: Schema.Boolean,
  764. image: Schema.Boolean,
  765. video: Schema.Boolean,
  766. pdf: Schema.Boolean,
  767. })
  768. const ProviderInterleaved = Schema.Union([
  769. Schema.Boolean,
  770. Schema.Struct({
  771. field: Schema.Literals(["reasoning_content", "reasoning_details"]),
  772. }),
  773. ])
  774. const ProviderCapabilities = Schema.Struct({
  775. temperature: Schema.Boolean,
  776. reasoning: Schema.Boolean,
  777. attachment: Schema.Boolean,
  778. toolcall: Schema.Boolean,
  779. input: ProviderModalities,
  780. output: ProviderModalities,
  781. interleaved: ProviderInterleaved,
  782. })
  783. const ProviderCacheCost = Schema.Struct({
  784. read: Schema.Number,
  785. write: Schema.Number,
  786. })
  787. const ProviderCost = Schema.Struct({
  788. input: Schema.Number,
  789. output: Schema.Number,
  790. cache: ProviderCacheCost,
  791. experimentalOver200K: Schema.optional(
  792. Schema.Struct({
  793. input: Schema.Number,
  794. output: Schema.Number,
  795. cache: ProviderCacheCost,
  796. }),
  797. ),
  798. })
  799. const ProviderLimit = Schema.Struct({
  800. context: Schema.Number,
  801. input: Schema.optional(Schema.Number),
  802. output: Schema.Number,
  803. })
  804. export const Model = Schema.Struct({
  805. id: ModelID,
  806. providerID: ProviderID,
  807. api: ProviderApiInfo,
  808. name: Schema.String,
  809. family: Schema.optional(Schema.String),
  810. capabilities: ProviderCapabilities,
  811. cost: ProviderCost,
  812. limit: ProviderLimit,
  813. status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
  814. options: Schema.Record(Schema.String, Schema.Any),
  815. headers: Schema.Record(Schema.String, Schema.String),
  816. release_date: Schema.String,
  817. variants: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))),
  818. })
  819. .annotate({ identifier: "Model" })
  820. .pipe(withStatics((s) => ({ zod: zod(s) })))
  821. export type Model = Types.DeepMutable<Schema.Schema.Type<typeof Model>>
  822. export const Info = Schema.Struct({
  823. id: ProviderID,
  824. name: Schema.String,
  825. source: Schema.Literals(["env", "config", "custom", "api"]),
  826. env: Schema.Array(Schema.String),
  827. key: Schema.optional(Schema.String),
  828. options: Schema.Record(Schema.String, Schema.Any),
  829. models: Schema.Record(Schema.String, Model),
  830. })
  831. .annotate({ identifier: "Provider" })
  832. .pipe(withStatics((s) => ({ zod: zod(s) })))
  833. export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
  834. const DefaultModelIDs = Schema.Record(Schema.String, Schema.String)
  835. export const ListResult = Schema.Struct({
  836. all: Schema.Array(Info),
  837. default: DefaultModelIDs,
  838. connected: Schema.Array(Schema.String),
  839. }).pipe(withStatics((s) => ({ zod: zod(s) })))
  840. export type ListResult = Types.DeepMutable<Schema.Schema.Type<typeof ListResult>>
  841. export const ConfigProvidersResult = Schema.Struct({
  842. providers: Schema.Array(Info),
  843. default: DefaultModelIDs,
  844. }).pipe(withStatics((s) => ({ zod: zod(s) })))
  845. export type ConfigProvidersResult = Types.DeepMutable<Schema.Schema.Type<typeof ConfigProvidersResult>>
  846. export function defaultModelIDs<T extends { models: Record<string, { id: string }> }>(providers: Record<string, T>) {
  847. return mapValues(providers, (item) => sort(Object.values(item.models))[0].id)
  848. }
  849. export interface Interface {
  850. readonly list: () => Effect.Effect<Record<ProviderID, Info>>
  851. readonly getProvider: (providerID: ProviderID) => Effect.Effect<Info>
  852. readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model>
  853. readonly getLanguage: (model: Model) => Effect.Effect<LanguageModelV3>
  854. readonly closest: (
  855. providerID: ProviderID,
  856. query: string[],
  857. ) => Effect.Effect<{ providerID: ProviderID; modelID: string } | undefined>
  858. readonly getSmallModel: (providerID: ProviderID) => Effect.Effect<Model | undefined>
  859. readonly defaultModel: () => Effect.Effect<{ providerID: ProviderID; modelID: ModelID }>
  860. }
  861. interface State {
  862. models: Map<string, LanguageModelV3>
  863. providers: Record<ProviderID, Info>
  864. sdk: Map<string, BundledSDK>
  865. modelLoaders: Record<string, CustomModelLoader>
  866. varsLoaders: Record<string, CustomVarsLoader>
  867. }
  868. export class Service extends Context.Service<Service, Interface>()("@opencode/Provider") {}
  869. function cost(c: ModelsDev.Model["cost"]): Model["cost"] {
  870. const result: Model["cost"] = {
  871. input: c?.input ?? 0,
  872. output: c?.output ?? 0,
  873. cache: {
  874. read: c?.cache_read ?? 0,
  875. write: c?.cache_write ?? 0,
  876. },
  877. }
  878. if (c?.context_over_200k) {
  879. result.experimentalOver200K = {
  880. cache: {
  881. read: c.context_over_200k.cache_read ?? 0,
  882. write: c.context_over_200k.cache_write ?? 0,
  883. },
  884. input: c.context_over_200k.input,
  885. output: c.context_over_200k.output,
  886. }
  887. }
  888. return result
  889. }
  890. function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
  891. const base: Model = {
  892. id: ModelID.make(model.id),
  893. providerID: ProviderID.make(provider.id),
  894. name: model.name,
  895. family: model.family,
  896. api: {
  897. id: model.id,
  898. url: model.provider?.api ?? provider.api ?? "",
  899. npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
  900. },
  901. status: model.status ?? "active",
  902. headers: {},
  903. options: {},
  904. cost: cost(model.cost),
  905. limit: {
  906. context: model.limit.context,
  907. input: model.limit.input,
  908. output: model.limit.output,
  909. },
  910. capabilities: {
  911. temperature: model.temperature ?? false,
  912. reasoning: model.reasoning ?? false,
  913. attachment: model.attachment ?? false,
  914. toolcall: model.tool_call ?? true,
  915. input: {
  916. text: model.modalities?.input?.includes("text") ?? false,
  917. audio: model.modalities?.input?.includes("audio") ?? false,
  918. image: model.modalities?.input?.includes("image") ?? false,
  919. video: model.modalities?.input?.includes("video") ?? false,
  920. pdf: model.modalities?.input?.includes("pdf") ?? false,
  921. },
  922. output: {
  923. text: model.modalities?.output?.includes("text") ?? false,
  924. audio: model.modalities?.output?.includes("audio") ?? false,
  925. image: model.modalities?.output?.includes("image") ?? false,
  926. video: model.modalities?.output?.includes("video") ?? false,
  927. pdf: model.modalities?.output?.includes("pdf") ?? false,
  928. },
  929. interleaved: model.interleaved ?? false,
  930. },
  931. release_date: model.release_date ?? "",
  932. variants: {},
  933. }
  934. return {
  935. ...base,
  936. variants: mapValues(ProviderTransform.variants(base), (v) => v),
  937. }
  938. }
  939. export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
  940. const models: Record<string, Model> = {}
  941. for (const [key, model] of Object.entries(provider.models)) {
  942. models[key] = fromModelsDevModel(provider, model)
  943. for (const [mode, opts] of Object.entries(model.experimental?.modes ?? {})) {
  944. const id = `${model.id}-${mode}`
  945. const base = fromModelsDevModel(provider, model)
  946. models[id] = {
  947. ...base,
  948. id: ModelID.make(id),
  949. name: `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`,
  950. cost: opts.cost ? mergeDeep(base.cost, cost(opts.cost)) : base.cost,
  951. options: opts.provider?.body
  952. ? Object.fromEntries(
  953. Object.entries(opts.provider.body).map(([k, v]) => [
  954. k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()),
  955. v,
  956. ]),
  957. )
  958. : base.options,
  959. headers: opts.provider?.headers ?? base.headers,
  960. }
  961. }
  962. }
  963. return {
  964. id: ProviderID.make(provider.id),
  965. source: "custom",
  966. name: provider.name,
  967. env: [...(provider.env ?? [])],
  968. options: {},
  969. models,
  970. }
  971. }
  972. const layer: Layer.Layer<
  973. Service,
  974. never,
  975. Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service
  976. > = Layer.effect(
  977. Service,
  978. Effect.gen(function* () {
  979. const fs = yield* AppFileSystem.Service
  980. const config = yield* Config.Service
  981. const auth = yield* Auth.Service
  982. const env = yield* Env.Service
  983. const plugin = yield* Plugin.Service
  984. const state = yield* InstanceState.make<State>(() =>
  985. Effect.gen(function* () {
  986. using _ = log.time("state")
  987. const bridge = yield* EffectBridge.make()
  988. const cfg = yield* config.get()
  989. const modelsDev = yield* Effect.promise(() => ModelsDev.get())
  990. const database = mapValues(modelsDev, fromModelsDevProvider)
  991. const providers: Record<ProviderID, Info> = {} as Record<ProviderID, Info>
  992. const languages = new Map<string, LanguageModelV3>()
  993. const modelLoaders: {
  994. [providerID: string]: CustomModelLoader
  995. } = {}
  996. const varsLoaders: {
  997. [providerID: string]: CustomVarsLoader
  998. } = {}
  999. const sdk = new Map<string, BundledSDK>()
  1000. const discoveryLoaders: {
  1001. [providerID: string]: CustomDiscoverModels
  1002. } = {}
  1003. const dep = {
  1004. auth: (id: string) => auth.get(id).pipe(Effect.orDie),
  1005. config: () => config.get(),
  1006. env: () => env.all(),
  1007. get: (key: string) => env.get(key),
  1008. }
  1009. log.info("init")
  1010. function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
  1011. const existing = providers[providerID]
  1012. if (existing) {
  1013. // @ts-expect-error
  1014. providers[providerID] = mergeDeep(existing, provider)
  1015. return
  1016. }
  1017. const match = database[providerID]
  1018. if (!match) return
  1019. // @ts-expect-error
  1020. providers[providerID] = mergeDeep(match, provider)
  1021. }
  1022. // load plugins first so config() hook runs before reading cfg.provider
  1023. const plugins = yield* plugin.list()
  1024. // now read config providers - includes any modifications from plugin config() hook
  1025. const configProviders = Object.entries(cfg.provider ?? {})
  1026. const disabled = new Set(cfg.disabled_providers ?? [])
  1027. const enabled = cfg.enabled_providers ? new Set(cfg.enabled_providers) : null
  1028. function isProviderAllowed(providerID: ProviderID): boolean {
  1029. if (enabled && !enabled.has(providerID)) return false
  1030. if (disabled.has(providerID)) return false
  1031. return true
  1032. }
  1033. // extend database from config
  1034. for (const [providerID, provider] of configProviders) {
  1035. const existing = database[providerID]
  1036. const parsed: Info = {
  1037. id: ProviderID.make(providerID),
  1038. name: provider.name ?? existing?.name ?? providerID,
  1039. env: provider.env ?? existing?.env ?? [],
  1040. options: mergeDeep(existing?.options ?? {}, provider.options ?? {}),
  1041. source: "config",
  1042. models: existing?.models ?? {},
  1043. }
  1044. for (const [modelID, model] of Object.entries(provider.models ?? {})) {
  1045. const existingModel = parsed.models[model.id ?? modelID]
  1046. const apiID = model.id ?? existingModel?.api.id ?? modelID
  1047. const apiNpm =
  1048. model.provider?.npm ??
  1049. provider.npm ??
  1050. existingModel?.api.npm ??
  1051. modelsDev[providerID]?.npm ??
  1052. "@ai-sdk/openai-compatible"
  1053. const name = iife(() => {
  1054. if (model.name) return model.name
  1055. if (model.id && model.id !== modelID) return modelID
  1056. return existingModel?.name ?? modelID
  1057. })
  1058. const parsedModel: Model = {
  1059. id: ModelID.make(modelID),
  1060. api: {
  1061. id: apiID,
  1062. npm: apiNpm,
  1063. url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api ?? "",
  1064. },
  1065. status: model.status ?? existingModel?.status ?? "active",
  1066. name,
  1067. providerID: ProviderID.make(providerID),
  1068. capabilities: {
  1069. temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false,
  1070. reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false,
  1071. attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false,
  1072. toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true,
  1073. input: {
  1074. text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true,
  1075. audio: model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false,
  1076. image: model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false,
  1077. video: model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false,
  1078. pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false,
  1079. },
  1080. output: {
  1081. text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true,
  1082. audio:
  1083. model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? false,
  1084. image:
  1085. model.modalities?.output?.includes("image") ?? existingModel?.capabilities.output.image ?? false,
  1086. video:
  1087. model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false,
  1088. pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
  1089. },
  1090. interleaved:
  1091. model.interleaved ??
  1092. existingModel?.capabilities.interleaved ??
  1093. (!existingModel && apiNpm === "@ai-sdk/openai-compatible" && apiID.includes("deepseek")
  1094. ? { field: "reasoning_content" }
  1095. : false),
  1096. },
  1097. cost: {
  1098. input: model?.cost?.input ?? existingModel?.cost?.input ?? 0,
  1099. output: model?.cost?.output ?? existingModel?.cost?.output ?? 0,
  1100. cache: {
  1101. read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0,
  1102. write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0,
  1103. },
  1104. },
  1105. options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}),
  1106. limit: {
  1107. context: model.limit?.context ?? existingModel?.limit?.context ?? 0,
  1108. input: model.limit?.input ?? existingModel?.limit?.input,
  1109. output: model.limit?.output ?? existingModel?.limit?.output ?? 0,
  1110. },
  1111. headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
  1112. family: model.family ?? existingModel?.family ?? "",
  1113. release_date: model.release_date ?? existingModel?.release_date ?? "",
  1114. variants: {},
  1115. }
  1116. const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
  1117. parsedModel.variants = mapValues(
  1118. pickBy(merged, (v) => !v.disabled),
  1119. (v) => omit(v, ["disabled"]),
  1120. )
  1121. parsed.models[modelID] = parsedModel
  1122. }
  1123. database[providerID] = parsed
  1124. }
  1125. // load env
  1126. const envs = yield* env.all()
  1127. for (const [id, provider] of Object.entries(database)) {
  1128. const providerID = ProviderID.make(id)
  1129. if (disabled.has(providerID)) continue
  1130. const apiKey = provider.env.map((item) => envs[item]).find(Boolean)
  1131. if (!apiKey) continue
  1132. mergeProvider(providerID, {
  1133. source: "env",
  1134. key: provider.env.length === 1 ? apiKey : undefined,
  1135. })
  1136. }
  1137. // load apikeys
  1138. const auths = yield* auth.all().pipe(Effect.orDie)
  1139. for (const [id, provider] of Object.entries(auths)) {
  1140. const providerID = ProviderID.make(id)
  1141. if (disabled.has(providerID)) continue
  1142. if (provider.type === "api") {
  1143. mergeProvider(providerID, {
  1144. source: "api",
  1145. key: provider.key,
  1146. })
  1147. }
  1148. }
  1149. // plugin auth loader - database now has entries for config providers
  1150. for (const plugin of plugins) {
  1151. if (!plugin.auth) continue
  1152. const providerID = ProviderID.make(plugin.auth.provider)
  1153. if (disabled.has(providerID)) continue
  1154. const stored = yield* auth.get(providerID).pipe(Effect.orDie)
  1155. if (!stored) continue
  1156. if (!plugin.auth.loader) continue
  1157. const options = yield* Effect.promise(() =>
  1158. plugin.auth!.loader!(
  1159. () => bridge.promise(auth.get(providerID).pipe(Effect.orDie)) as any,
  1160. database[plugin.auth!.provider],
  1161. ),
  1162. )
  1163. const opts = options ?? {}
  1164. const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
  1165. mergeProvider(providerID, patch)
  1166. }
  1167. for (const [id, fn] of Object.entries(custom(dep))) {
  1168. const providerID = ProviderID.make(id)
  1169. if (disabled.has(providerID)) continue
  1170. const data = database[providerID]
  1171. if (!data) {
  1172. log.error("Provider does not exist in model list " + providerID)
  1173. continue
  1174. }
  1175. const result = yield* fn(data)
  1176. if (result && (result.autoload || providers[providerID])) {
  1177. if (result.getModel) modelLoaders[providerID] = result.getModel
  1178. if (result.vars) varsLoaders[providerID] = result.vars
  1179. if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels
  1180. const opts = result.options ?? {}
  1181. const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
  1182. mergeProvider(providerID, patch)
  1183. }
  1184. }
  1185. // load config - re-apply with updated data
  1186. for (const [id, provider] of configProviders) {
  1187. const providerID = ProviderID.make(id)
  1188. const partial: Partial<Info> = { source: "config" }
  1189. if (provider.env) partial.env = provider.env
  1190. if (provider.name) partial.name = provider.name
  1191. if (provider.options) partial.options = provider.options
  1192. mergeProvider(providerID, partial)
  1193. }
  1194. const gitlab = ProviderID.make("gitlab")
  1195. if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) {
  1196. yield* Effect.promise(async () => {
  1197. try {
  1198. const discovered = await discoveryLoaders[gitlab]()
  1199. for (const [modelID, model] of Object.entries(discovered)) {
  1200. if (!providers[gitlab].models[modelID]) {
  1201. providers[gitlab].models[modelID] = model
  1202. }
  1203. }
  1204. } catch (e) {
  1205. log.warn("state discovery error", { id: "gitlab", error: e })
  1206. }
  1207. })
  1208. }
  1209. for (const hook of plugins) {
  1210. const p = hook.provider
  1211. const models = p?.models
  1212. if (!p || !models) continue
  1213. const providerID = ProviderID.make(p.id)
  1214. if (disabled.has(providerID)) continue
  1215. const provider = providers[providerID]
  1216. if (!provider) continue
  1217. const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
  1218. provider.models = yield* Effect.promise(async () => {
  1219. const next = await models(provider, { auth: pluginAuth })
  1220. return Object.fromEntries(
  1221. Object.entries(next).map(([id, model]) => [
  1222. id,
  1223. {
  1224. ...model,
  1225. id: ModelID.make(id),
  1226. providerID,
  1227. },
  1228. ]),
  1229. )
  1230. })
  1231. }
  1232. for (const [id, provider] of Object.entries(providers)) {
  1233. const providerID = ProviderID.make(id)
  1234. if (!isProviderAllowed(providerID)) {
  1235. delete providers[providerID]
  1236. continue
  1237. }
  1238. const configProvider = cfg.provider?.[providerID]
  1239. for (const [modelID, model] of Object.entries(provider.models)) {
  1240. model.api.id = model.api.id ?? model.id ?? modelID
  1241. if (
  1242. modelID === "gpt-5-chat-latest" ||
  1243. (providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat")
  1244. )
  1245. delete provider.models[modelID]
  1246. if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) delete provider.models[modelID]
  1247. if (model.status === "deprecated") delete provider.models[modelID]
  1248. if (
  1249. (configProvider?.blacklist && configProvider.blacklist.includes(modelID)) ||
  1250. (configProvider?.whitelist && !configProvider.whitelist.includes(modelID))
  1251. )
  1252. delete provider.models[modelID]
  1253. model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
  1254. const configVariants = configProvider?.models?.[modelID]?.variants
  1255. if (configVariants && model.variants) {
  1256. const merged = mergeDeep(model.variants, configVariants)
  1257. model.variants = mapValues(
  1258. pickBy(merged, (v) => !v.disabled),
  1259. (v) => omit(v, ["disabled"]),
  1260. )
  1261. }
  1262. }
  1263. if (Object.keys(provider.models).length === 0) {
  1264. delete providers[providerID]
  1265. continue
  1266. }
  1267. log.info("found", { providerID })
  1268. }
  1269. return {
  1270. models: languages,
  1271. providers,
  1272. sdk,
  1273. modelLoaders,
  1274. varsLoaders,
  1275. }
  1276. }),
  1277. )
  1278. const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers))
  1279. async function resolveSDK(model: Model, s: State, envs: Record<string, string | undefined>) {
  1280. try {
  1281. using _ = log.time("getSDK", {
  1282. providerID: model.providerID,
  1283. })
  1284. const provider = s.providers[model.providerID]
  1285. const options = { ...provider.options }
  1286. if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) {
  1287. delete options.fetch
  1288. }
  1289. if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) {
  1290. options["includeUsage"] = true
  1291. }
  1292. const baseURL = iife(() => {
  1293. let url =
  1294. typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url
  1295. if (!url) return
  1296. const loader = s.varsLoaders[model.providerID]
  1297. if (loader) {
  1298. const vars = loader(options)
  1299. for (const [key, value] of Object.entries(vars)) {
  1300. const field = "${" + key + "}"
  1301. url = url.replaceAll(field, value)
  1302. }
  1303. }
  1304. url = url.replace(/\$\{([^}]+)\}/g, (item, key) => {
  1305. const val = envs[String(key)]
  1306. return val ?? item
  1307. })
  1308. return url
  1309. })
  1310. if (baseURL !== undefined) options["baseURL"] = baseURL
  1311. if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key
  1312. if (model.headers)
  1313. options["headers"] = {
  1314. ...options["headers"],
  1315. ...model.headers,
  1316. }
  1317. const key = Hash.fast(
  1318. JSON.stringify({
  1319. providerID: model.providerID,
  1320. npm: model.api.npm,
  1321. options,
  1322. }),
  1323. )
  1324. const existing = s.sdk.get(key)
  1325. if (existing) return existing
  1326. const customFetch = options["fetch"]
  1327. const chunkTimeout = options["chunkTimeout"]
  1328. delete options["chunkTimeout"]
  1329. options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
  1330. const fetchFn = customFetch ?? fetch
  1331. const opts = init ?? {}
  1332. const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined
  1333. const signals: AbortSignal[] = []
  1334. if (opts.signal) signals.push(opts.signal)
  1335. if (chunkAbortCtl) signals.push(chunkAbortCtl.signal)
  1336. if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false)
  1337. signals.push(AbortSignal.timeout(options["timeout"]))
  1338. const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals)
  1339. if (combined) opts.signal = combined
  1340. // Strip openai itemId metadata following what codex does
  1341. if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") {
  1342. const body = JSON.parse(opts.body as string)
  1343. const isAzure = model.providerID.includes("azure")
  1344. const keepIds = isAzure && body.store === true
  1345. if (!keepIds && Array.isArray(body.input)) {
  1346. for (const item of body.input) {
  1347. if ("id" in item) {
  1348. delete item.id
  1349. }
  1350. }
  1351. opts.body = JSON.stringify(body)
  1352. }
  1353. }
  1354. const res = await fetchFn(input, {
  1355. ...opts,
  1356. // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
  1357. timeout: false,
  1358. })
  1359. if (!chunkAbortCtl) return res
  1360. return wrapSSE(res, chunkTimeout, chunkAbortCtl)
  1361. }
  1362. const bundledLoader = BUNDLED_PROVIDERS[model.api.npm]
  1363. if (bundledLoader) {
  1364. log.info("using bundled provider", {
  1365. providerID: model.providerID,
  1366. pkg: model.api.npm,
  1367. })
  1368. const factory = await bundledLoader()
  1369. const loaded = factory({
  1370. name: model.providerID,
  1371. ...options,
  1372. })
  1373. s.sdk.set(key, loaded)
  1374. return loaded as SDK
  1375. }
  1376. let installedPath: string
  1377. if (!model.api.npm.startsWith("file://")) {
  1378. const item = await Npm.add(model.api.npm)
  1379. if (!item.entrypoint) throw new Error(`Package ${model.api.npm} has no import entrypoint`)
  1380. installedPath = item.entrypoint
  1381. } else {
  1382. log.info("loading local provider", { pkg: model.api.npm })
  1383. installedPath = model.api.npm
  1384. }
  1385. // `installedPath` is a local entry path or an existing `file://` URL. Normalize
  1386. // only path inputs so Node on Windows accepts the dynamic import.
  1387. const importSpec = installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href
  1388. const mod = await import(importSpec)
  1389. const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
  1390. const loaded = fn({
  1391. name: model.providerID,
  1392. ...options,
  1393. })
  1394. s.sdk.set(key, loaded)
  1395. return loaded as SDK
  1396. } catch (e) {
  1397. throw new InitError({ providerID: model.providerID }, { cause: e })
  1398. }
  1399. }
  1400. const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) =>
  1401. InstanceState.use(state, (s) => s.providers[providerID]),
  1402. )
  1403. const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) {
  1404. const s = yield* InstanceState.get(state)
  1405. const provider = s.providers[providerID]
  1406. if (!provider) {
  1407. const available = Object.keys(s.providers)
  1408. const matches = fuzzysort.go(providerID, available, { limit: 3, threshold: -10000 })
  1409. throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) })
  1410. }
  1411. const info = provider.models[modelID]
  1412. if (!info) {
  1413. const available = Object.keys(provider.models)
  1414. const matches = fuzzysort.go(modelID, available, { limit: 3, threshold: -10000 })
  1415. throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) })
  1416. }
  1417. return info
  1418. })
  1419. const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) {
  1420. const s = yield* InstanceState.get(state)
  1421. const envs = yield* env.all()
  1422. const key = `${model.providerID}/${model.id}`
  1423. if (s.models.has(key)) return s.models.get(key)!
  1424. return yield* Effect.promise(async () => {
  1425. const provider = s.providers[model.providerID]
  1426. const sdk = await resolveSDK(model, s, envs)
  1427. try {
  1428. const language = s.modelLoaders[model.providerID]
  1429. ? await s.modelLoaders[model.providerID](sdk, model.api.id, {
  1430. ...provider.options,
  1431. ...model.options,
  1432. })
  1433. : sdk.languageModel(model.api.id)
  1434. s.models.set(key, language)
  1435. return language
  1436. } catch (e) {
  1437. if (e instanceof NoSuchModelError)
  1438. throw new ModelNotFoundError(
  1439. {
  1440. modelID: model.id,
  1441. providerID: model.providerID,
  1442. },
  1443. { cause: e },
  1444. )
  1445. throw e
  1446. }
  1447. })
  1448. })
  1449. const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
  1450. const s = yield* InstanceState.get(state)
  1451. const provider = s.providers[providerID]
  1452. if (!provider) return undefined
  1453. for (const item of query) {
  1454. for (const modelID of Object.keys(provider.models)) {
  1455. if (modelID.includes(item)) return { providerID, modelID }
  1456. }
  1457. }
  1458. return undefined
  1459. })
  1460. const getSmallModel = Effect.fn("Provider.getSmallModel")(function* (providerID: ProviderID) {
  1461. const cfg = yield* config.get()
  1462. if (cfg.small_model) {
  1463. const parsed = parseModel(cfg.small_model)
  1464. return yield* getModel(parsed.providerID, parsed.modelID)
  1465. }
  1466. const s = yield* InstanceState.get(state)
  1467. const provider = s.providers[providerID]
  1468. if (!provider) return undefined
  1469. let priority = [
  1470. "claude-haiku-4-5",
  1471. "claude-haiku-4.5",
  1472. "3-5-haiku",
  1473. "3.5-haiku",
  1474. "gemini-3-flash",
  1475. "gemini-2.5-flash",
  1476. "gpt-5-nano",
  1477. ]
  1478. if (providerID.startsWith("opencode")) {
  1479. priority = ["gpt-5-nano"]
  1480. }
  1481. if (providerID.startsWith("github-copilot")) {
  1482. priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority]
  1483. }
  1484. for (const item of priority) {
  1485. if (providerID === ProviderID.amazonBedrock) {
  1486. const crossRegionPrefixes = ["global.", "us.", "eu."]
  1487. const candidates = Object.keys(provider.models).filter((m) => m.includes(item))
  1488. const globalMatch = candidates.find((m) => m.startsWith("global."))
  1489. if (globalMatch) return yield* getModel(providerID, ModelID.make(globalMatch))
  1490. const region = provider.options?.region
  1491. if (region) {
  1492. const regionPrefix = region.split("-")[0]
  1493. if (regionPrefix === "us" || regionPrefix === "eu") {
  1494. const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`))
  1495. if (regionalMatch) return yield* getModel(providerID, ModelID.make(regionalMatch))
  1496. }
  1497. }
  1498. const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p)))
  1499. if (unprefixed) return yield* getModel(providerID, ModelID.make(unprefixed))
  1500. } else {
  1501. for (const model of Object.keys(provider.models)) {
  1502. if (model.includes(item)) return yield* getModel(providerID, ModelID.make(model))
  1503. }
  1504. }
  1505. }
  1506. return undefined
  1507. })
  1508. const defaultModel = Effect.fn("Provider.defaultModel")(function* () {
  1509. const cfg = yield* config.get()
  1510. if (cfg.model) return parseModel(cfg.model)
  1511. const s = yield* InstanceState.get(state)
  1512. const recent = yield* fs.readJson(path.join(Global.Path.state, "model.json")).pipe(
  1513. Effect.map((x): { providerID: ProviderID; modelID: ModelID }[] => {
  1514. if (!isRecord(x) || !Array.isArray(x.recent)) return []
  1515. return x.recent.flatMap((item) => {
  1516. if (!isRecord(item)) return []
  1517. if (typeof item.providerID !== "string") return []
  1518. if (typeof item.modelID !== "string") return []
  1519. return [{ providerID: ProviderID.make(item.providerID), modelID: ModelID.make(item.modelID) }]
  1520. })
  1521. }),
  1522. Effect.catch(() => Effect.succeed([] as { providerID: ProviderID; modelID: ModelID }[])),
  1523. )
  1524. for (const entry of recent) {
  1525. const provider = s.providers[entry.providerID]
  1526. if (!provider) continue
  1527. if (!provider.models[entry.modelID]) continue
  1528. return { providerID: entry.providerID, modelID: entry.modelID }
  1529. }
  1530. const provider = Object.values(s.providers).find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id))
  1531. if (!provider) throw new Error("no providers found")
  1532. const [model] = sort(Object.values(provider.models))
  1533. if (!model) throw new Error("no models found")
  1534. return {
  1535. providerID: provider.id,
  1536. modelID: model.id,
  1537. }
  1538. })
  1539. return Service.of({ list, getProvider, getModel, getLanguage, closest, getSmallModel, defaultModel })
  1540. }),
  1541. )
  1542. export const defaultLayer = Layer.suspend(() =>
  1543. layer.pipe(
  1544. Layer.provide(AppFileSystem.defaultLayer),
  1545. Layer.provide(Env.defaultLayer),
  1546. Layer.provide(Config.defaultLayer),
  1547. Layer.provide(Auth.defaultLayer),
  1548. Layer.provide(Plugin.defaultLayer),
  1549. ),
  1550. )
  1551. const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
  1552. export function sort<T extends { id: string }>(models: T[]) {
  1553. return sortBy(
  1554. models,
  1555. [(model) => priority.findIndex((filter) => model.id.includes(filter)), "desc"],
  1556. [(model) => (model.id.includes("latest") ? 0 : 1), "asc"],
  1557. [(model) => model.id, "desc"],
  1558. )
  1559. }
  1560. export function parseModel(model: string) {
  1561. const [providerID, ...rest] = model.split("/")
  1562. return {
  1563. providerID: ProviderID.make(providerID),
  1564. modelID: ModelID.make(rest.join("/")),
  1565. }
  1566. }
  1567. export const ModelNotFoundError = namedSchemaError("ProviderModelNotFoundError", {
  1568. providerID: ProviderID,
  1569. modelID: ModelID,
  1570. suggestions: Schema.optional(Schema.Array(Schema.String)),
  1571. })
  1572. export const InitError = namedSchemaError("ProviderInitError", {
  1573. providerID: ProviderID,
  1574. })