provider.ts 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219
  1. import z from "zod"
  2. import fuzzysort from "fuzzysort"
  3. import { Config } from "../config/config"
  4. import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
  5. import { NoSuchModelError, type Provider as SDK } from "ai"
  6. import { Log } from "../util/log"
  7. import { BunProc } from "../bun"
  8. import { Plugin } from "../plugin"
  9. import { ModelsDev } from "./models"
  10. import { NamedError } from "@opencode-ai/util/error"
  11. import { Auth } from "../auth"
  12. import { Env } from "../env"
  13. import { Instance } from "../project/instance"
  14. import { Flag } from "../flag/flag"
  15. import { iife } from "@/util/iife"
  16. // Direct imports for bundled providers
  17. import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
  18. import { createAnthropic } from "@ai-sdk/anthropic"
  19. import { createAzure } from "@ai-sdk/azure"
  20. import { createGoogleGenerativeAI } from "@ai-sdk/google"
  21. import { createVertex } from "@ai-sdk/google-vertex"
  22. import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic"
  23. import { createOpenAI } from "@ai-sdk/openai"
  24. import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
  25. import { createOpenRouter, type LanguageModelV2 } from "@openrouter/ai-sdk-provider"
  26. import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/openai-compatible/src"
  27. import { createXai } from "@ai-sdk/xai"
  28. import { createMistral } from "@ai-sdk/mistral"
  29. import { createGroq } from "@ai-sdk/groq"
  30. import { createDeepInfra } from "@ai-sdk/deepinfra"
  31. import { createCerebras } from "@ai-sdk/cerebras"
  32. import { createCohere } from "@ai-sdk/cohere"
  33. import { createGateway } from "@ai-sdk/gateway"
  34. import { createTogetherAI } from "@ai-sdk/togetherai"
  35. import { createPerplexity } from "@ai-sdk/perplexity"
  36. import { createVercel } from "@ai-sdk/vercel"
  37. import { createGitLab } from "@gitlab/gitlab-ai-provider"
  38. import { ProviderTransform } from "./transform"
  39. export namespace Provider {
  40. const log = Log.create({ service: "provider" })
  41. function isGpt5OrLater(modelID: string): boolean {
  42. const match = /^gpt-(\d+)/.exec(modelID)
  43. if (!match) {
  44. return false
  45. }
  46. return Number(match[1]) >= 5
  47. }
  48. function shouldUseCopilotResponsesApi(modelID: string): boolean {
  49. return isGpt5OrLater(modelID) && !modelID.startsWith("gpt-5-mini")
  50. }
  51. const BUNDLED_PROVIDERS: Record<string, (options: any) => SDK> = {
  52. "@ai-sdk/amazon-bedrock": createAmazonBedrock,
  53. "@ai-sdk/anthropic": createAnthropic,
  54. "@ai-sdk/azure": createAzure,
  55. "@ai-sdk/google": createGoogleGenerativeAI,
  56. "@ai-sdk/google-vertex": createVertex,
  57. "@ai-sdk/google-vertex/anthropic": createVertexAnthropic,
  58. "@ai-sdk/openai": createOpenAI,
  59. "@ai-sdk/openai-compatible": createOpenAICompatible,
  60. "@openrouter/ai-sdk-provider": createOpenRouter,
  61. "@ai-sdk/xai": createXai,
  62. "@ai-sdk/mistral": createMistral,
  63. "@ai-sdk/groq": createGroq,
  64. "@ai-sdk/deepinfra": createDeepInfra,
  65. "@ai-sdk/cerebras": createCerebras,
  66. "@ai-sdk/cohere": createCohere,
  67. "@ai-sdk/gateway": createGateway,
  68. "@ai-sdk/togetherai": createTogetherAI,
  69. "@ai-sdk/perplexity": createPerplexity,
  70. "@ai-sdk/vercel": createVercel,
  71. "@gitlab/gitlab-ai-provider": createGitLab,
  72. // @ts-ignore (TODO: kill this code so we dont have to maintain it)
  73. "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
  74. }
  75. type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
  76. type CustomLoader = (provider: Info) => Promise<{
  77. autoload: boolean
  78. getModel?: CustomModelLoader
  79. options?: Record<string, any>
  80. }>
  81. const CUSTOM_LOADERS: Record<string, CustomLoader> = {
  82. async anthropic() {
  83. return {
  84. autoload: false,
  85. options: {
  86. headers: {
  87. "anthropic-beta":
  88. "claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
  89. },
  90. },
  91. }
  92. },
  93. async opencode(input) {
  94. const hasKey = await (async () => {
  95. const env = Env.all()
  96. if (input.env.some((item) => env[item])) return true
  97. if (await Auth.get(input.id)) return true
  98. const config = await Config.get()
  99. if (config.provider?.["opencode"]?.options?.apiKey) return true
  100. return false
  101. })()
  102. if (!hasKey) {
  103. for (const [key, value] of Object.entries(input.models)) {
  104. if (value.cost.input === 0) continue
  105. delete input.models[key]
  106. }
  107. }
  108. return {
  109. autoload: Object.keys(input.models).length > 0,
  110. options: hasKey ? {} : { apiKey: "public" },
  111. }
  112. },
  113. openai: async () => {
  114. return {
  115. autoload: false,
  116. async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
  117. return sdk.responses(modelID)
  118. },
  119. options: {},
  120. }
  121. },
  122. "github-copilot": async () => {
  123. return {
  124. autoload: false,
  125. async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
  126. if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID)
  127. return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
  128. },
  129. options: {},
  130. }
  131. },
  132. "github-copilot-enterprise": async () => {
  133. return {
  134. autoload: false,
  135. async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
  136. if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID)
  137. return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
  138. },
  139. options: {},
  140. }
  141. },
  142. azure: async () => {
  143. return {
  144. autoload: false,
  145. async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
  146. if (options?.["useCompletionUrls"]) {
  147. return sdk.chat(modelID)
  148. } else {
  149. return sdk.responses(modelID)
  150. }
  151. },
  152. options: {},
  153. }
  154. },
  155. "azure-cognitive-services": async () => {
  156. const resourceName = Env.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
  157. return {
  158. autoload: false,
  159. async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
  160. if (options?.["useCompletionUrls"]) {
  161. return sdk.chat(modelID)
  162. } else {
  163. return sdk.responses(modelID)
  164. }
  165. },
  166. options: {
  167. baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
  168. },
  169. }
  170. },
  171. "amazon-bedrock": async () => {
  172. const config = await Config.get()
  173. const providerConfig = config.provider?.["amazon-bedrock"]
  174. const auth = await Auth.get("amazon-bedrock")
  175. // Region precedence: 1) config file, 2) env var, 3) default
  176. const configRegion = providerConfig?.options?.region
  177. const envRegion = Env.get("AWS_REGION")
  178. const defaultRegion = configRegion ?? envRegion ?? "us-east-1"
  179. // Profile: config file takes precedence over env var
  180. const configProfile = providerConfig?.options?.profile
  181. const envProfile = Env.get("AWS_PROFILE")
  182. const profile = configProfile ?? envProfile
  183. const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID")
  184. const awsBearerToken = iife(() => {
  185. const envToken = Env.get("AWS_BEARER_TOKEN_BEDROCK")
  186. if (envToken) return envToken
  187. if (auth?.type === "api") {
  188. Env.set("AWS_BEARER_TOKEN_BEDROCK", auth.key)
  189. return auth.key
  190. }
  191. return undefined
  192. })
  193. const awsWebIdentityTokenFile = Env.get("AWS_WEB_IDENTITY_TOKEN_FILE")
  194. if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile) return { autoload: false }
  195. const providerOptions: AmazonBedrockProviderSettings = {
  196. region: defaultRegion,
  197. }
  198. // Only use credential chain if no bearer token exists
  199. // Bearer token takes precedence over credential chain (profiles, access keys, IAM roles, web identity tokens)
  200. if (!awsBearerToken) {
  201. const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
  202. // Build credential provider options (only pass profile if specified)
  203. const credentialProviderOptions = profile ? { profile } : {}
  204. providerOptions.credentialProvider = fromNodeProviderChain(credentialProviderOptions)
  205. }
  206. // Add custom endpoint if specified (endpoint takes precedence over baseURL)
  207. const endpoint = providerConfig?.options?.endpoint ?? providerConfig?.options?.baseURL
  208. if (endpoint) {
  209. providerOptions.baseURL = endpoint
  210. }
  211. return {
  212. autoload: true,
  213. options: providerOptions,
  214. async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
  215. // Skip region prefixing if model already has a cross-region inference profile prefix
  216. if (modelID.startsWith("global.") || modelID.startsWith("jp.")) {
  217. return sdk.languageModel(modelID)
  218. }
  219. // Region resolution precedence (highest to lowest):
  220. // 1. options.region from opencode.json provider config
  221. // 2. defaultRegion from AWS_REGION environment variable
  222. // 3. Default "us-east-1" (baked into defaultRegion)
  223. const region = options?.region ?? defaultRegion
  224. let regionPrefix = region.split("-")[0]
  225. switch (regionPrefix) {
  226. case "us": {
  227. const modelRequiresPrefix = [
  228. "nova-micro",
  229. "nova-lite",
  230. "nova-pro",
  231. "nova-premier",
  232. "nova-2",
  233. "claude",
  234. "deepseek",
  235. ].some((m) => modelID.includes(m))
  236. const isGovCloud = region.startsWith("us-gov")
  237. if (modelRequiresPrefix && !isGovCloud) {
  238. modelID = `${regionPrefix}.${modelID}`
  239. }
  240. break
  241. }
  242. case "eu": {
  243. const regionRequiresPrefix = [
  244. "eu-west-1",
  245. "eu-west-2",
  246. "eu-west-3",
  247. "eu-north-1",
  248. "eu-central-1",
  249. "eu-south-1",
  250. "eu-south-2",
  251. ].some((r) => region.includes(r))
  252. const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((m) =>
  253. modelID.includes(m),
  254. )
  255. if (regionRequiresPrefix && modelRequiresPrefix) {
  256. modelID = `${regionPrefix}.${modelID}`
  257. }
  258. break
  259. }
  260. case "ap": {
  261. const isAustraliaRegion = ["ap-southeast-2", "ap-southeast-4"].includes(region)
  262. const isTokyoRegion = region === "ap-northeast-1"
  263. if (
  264. isAustraliaRegion &&
  265. ["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((m) => modelID.includes(m))
  266. ) {
  267. regionPrefix = "au"
  268. modelID = `${regionPrefix}.${modelID}`
  269. } else if (isTokyoRegion) {
  270. // Tokyo region uses jp. prefix for cross-region inference
  271. const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) =>
  272. modelID.includes(m),
  273. )
  274. if (modelRequiresPrefix) {
  275. regionPrefix = "jp"
  276. modelID = `${regionPrefix}.${modelID}`
  277. }
  278. } else {
  279. // Other APAC regions use apac. prefix
  280. const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) =>
  281. modelID.includes(m),
  282. )
  283. if (modelRequiresPrefix) {
  284. regionPrefix = "apac"
  285. modelID = `${regionPrefix}.${modelID}`
  286. }
  287. }
  288. break
  289. }
  290. }
  291. return sdk.languageModel(modelID)
  292. },
  293. }
  294. },
  295. openrouter: async () => {
  296. return {
  297. autoload: false,
  298. options: {
  299. headers: {
  300. "HTTP-Referer": "https://opencode.ai/",
  301. "X-Title": "opencode",
  302. },
  303. },
  304. }
  305. },
  306. vercel: async () => {
  307. return {
  308. autoload: false,
  309. options: {
  310. headers: {
  311. "http-referer": "https://opencode.ai/",
  312. "x-title": "opencode",
  313. },
  314. },
  315. }
  316. },
  317. "google-vertex": async () => {
  318. const project = Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
  319. const location = Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-east5"
  320. const autoload = Boolean(project)
  321. if (!autoload) return { autoload: false }
  322. return {
  323. autoload: true,
  324. options: {
  325. project,
  326. location,
  327. },
  328. async getModel(sdk: any, modelID: string) {
  329. const id = String(modelID).trim()
  330. return sdk.languageModel(id)
  331. },
  332. }
  333. },
  334. "google-vertex-anthropic": async () => {
  335. const project = Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
  336. const location = Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "global"
  337. const autoload = Boolean(project)
  338. if (!autoload) return { autoload: false }
  339. return {
  340. autoload: true,
  341. options: {
  342. project,
  343. location,
  344. },
  345. async getModel(sdk: any, modelID) {
  346. const id = String(modelID).trim()
  347. return sdk.languageModel(id)
  348. },
  349. }
  350. },
  351. "sap-ai-core": async () => {
  352. const auth = await Auth.get("sap-ai-core")
  353. const envServiceKey = iife(() => {
  354. const envAICoreServiceKey = Env.get("AICORE_SERVICE_KEY")
  355. if (envAICoreServiceKey) return envAICoreServiceKey
  356. if (auth?.type === "api") {
  357. Env.set("AICORE_SERVICE_KEY", auth.key)
  358. return auth.key
  359. }
  360. return undefined
  361. })
  362. const deploymentId = Env.get("AICORE_DEPLOYMENT_ID")
  363. const resourceGroup = Env.get("AICORE_RESOURCE_GROUP")
  364. return {
  365. autoload: !!envServiceKey,
  366. options: envServiceKey ? { deploymentId, resourceGroup } : {},
  367. async getModel(sdk: any, modelID: string) {
  368. return sdk(modelID)
  369. },
  370. }
  371. },
  372. zenmux: async () => {
  373. return {
  374. autoload: false,
  375. options: {
  376. headers: {
  377. "HTTP-Referer": "https://opencode.ai/",
  378. "X-Title": "opencode",
  379. },
  380. },
  381. }
  382. },
  383. gitlab: async (input) => {
  384. const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com"
  385. const auth = await Auth.get(input.id)
  386. const apiKey = await (async () => {
  387. if (auth?.type === "oauth") return auth.access
  388. if (auth?.type === "api") return auth.key
  389. return Env.get("GITLAB_TOKEN")
  390. })()
  391. const config = await Config.get()
  392. const providerConfig = config.provider?.["gitlab"]
  393. return {
  394. autoload: !!apiKey,
  395. options: {
  396. instanceUrl,
  397. apiKey,
  398. featureFlags: {
  399. duo_agent_platform_agentic_chat: true,
  400. duo_agent_platform: true,
  401. ...(providerConfig?.options?.featureFlags || {}),
  402. },
  403. },
  404. async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string) {
  405. return sdk.agenticChat(modelID, {
  406. featureFlags: {
  407. duo_agent_platform_agentic_chat: true,
  408. duo_agent_platform: true,
  409. ...(providerConfig?.options?.featureFlags || {}),
  410. },
  411. })
  412. },
  413. }
  414. },
  415. "cloudflare-ai-gateway": async (input) => {
  416. const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
  417. const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")
  418. if (!accountId || !gateway) return { autoload: false }
  419. // Get API token from env or auth prompt
  420. const apiToken = await (async () => {
  421. const envToken = Env.get("CLOUDFLARE_API_TOKEN")
  422. if (envToken) return envToken
  423. const auth = await Auth.get(input.id)
  424. if (auth?.type === "api") return auth.key
  425. return undefined
  426. })()
  427. return {
  428. autoload: true,
  429. async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
  430. return sdk.languageModel(modelID)
  431. },
  432. options: {
  433. baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gateway}/compat`,
  434. headers: {
  435. // Cloudflare AI Gateway uses cf-aig-authorization for authenticated gateways
  436. // This enables Unified Billing where Cloudflare handles upstream provider auth
  437. ...(apiToken ? { "cf-aig-authorization": `Bearer ${apiToken}` } : {}),
  438. "HTTP-Referer": "https://opencode.ai/",
  439. "X-Title": "opencode",
  440. },
  441. // Custom fetch to handle parameter transformation and auth
  442. fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
  443. const headers = new Headers(init?.headers)
  444. // Strip Authorization header - AI Gateway uses cf-aig-authorization instead
  445. headers.delete("Authorization")
  446. // Transform max_tokens to max_completion_tokens for newer models
  447. if (init?.body && init.method === "POST") {
  448. try {
  449. const body = JSON.parse(init.body as string)
  450. if (body.max_tokens !== undefined && !body.max_completion_tokens) {
  451. body.max_completion_tokens = body.max_tokens
  452. delete body.max_tokens
  453. init = { ...init, body: JSON.stringify(body) }
  454. }
  455. } catch (e) {
  456. // If body parsing fails, continue with original request
  457. }
  458. }
  459. return fetch(input, { ...init, headers })
  460. },
  461. },
  462. }
  463. },
  464. cerebras: async () => {
  465. return {
  466. autoload: false,
  467. options: {
  468. headers: {
  469. "X-Cerebras-3rd-Party-Integration": "opencode",
  470. },
  471. },
  472. }
  473. },
  474. }
  475. export const Model = z
  476. .object({
  477. id: z.string(),
  478. providerID: z.string(),
  479. api: z.object({
  480. id: z.string(),
  481. url: z.string(),
  482. npm: z.string(),
  483. }),
  484. name: z.string(),
  485. family: z.string().optional(),
  486. capabilities: z.object({
  487. temperature: z.boolean(),
  488. reasoning: z.boolean(),
  489. attachment: z.boolean(),
  490. toolcall: z.boolean(),
  491. input: z.object({
  492. text: z.boolean(),
  493. audio: z.boolean(),
  494. image: z.boolean(),
  495. video: z.boolean(),
  496. pdf: z.boolean(),
  497. }),
  498. output: z.object({
  499. text: z.boolean(),
  500. audio: z.boolean(),
  501. image: z.boolean(),
  502. video: z.boolean(),
  503. pdf: z.boolean(),
  504. }),
  505. interleaved: z.union([
  506. z.boolean(),
  507. z.object({
  508. field: z.enum(["reasoning_content", "reasoning_details"]),
  509. }),
  510. ]),
  511. }),
  512. cost: z.object({
  513. input: z.number(),
  514. output: z.number(),
  515. cache: z.object({
  516. read: z.number(),
  517. write: z.number(),
  518. }),
  519. experimentalOver200K: z
  520. .object({
  521. input: z.number(),
  522. output: z.number(),
  523. cache: z.object({
  524. read: z.number(),
  525. write: z.number(),
  526. }),
  527. })
  528. .optional(),
  529. }),
  530. limit: z.object({
  531. context: z.number(),
  532. input: z.number().optional(),
  533. output: z.number(),
  534. }),
  535. status: z.enum(["alpha", "beta", "deprecated", "active"]),
  536. options: z.record(z.string(), z.any()),
  537. headers: z.record(z.string(), z.string()),
  538. release_date: z.string(),
  539. variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
  540. })
  541. .meta({
  542. ref: "Model",
  543. })
  544. export type Model = z.infer<typeof Model>
  545. export const Info = z
  546. .object({
  547. id: z.string(),
  548. name: z.string(),
  549. source: z.enum(["env", "config", "custom", "api"]),
  550. env: z.string().array(),
  551. key: z.string().optional(),
  552. options: z.record(z.string(), z.any()),
  553. models: z.record(z.string(), Model),
  554. })
  555. .meta({
  556. ref: "Provider",
  557. })
  558. export type Info = z.infer<typeof Info>
  559. function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
  560. const m: Model = {
  561. id: model.id,
  562. providerID: provider.id,
  563. name: model.name,
  564. family: model.family,
  565. api: {
  566. id: model.id,
  567. url: provider.api!,
  568. npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
  569. },
  570. status: model.status ?? "active",
  571. headers: model.headers ?? {},
  572. options: model.options ?? {},
  573. cost: {
  574. input: model.cost?.input ?? 0,
  575. output: model.cost?.output ?? 0,
  576. cache: {
  577. read: model.cost?.cache_read ?? 0,
  578. write: model.cost?.cache_write ?? 0,
  579. },
  580. experimentalOver200K: model.cost?.context_over_200k
  581. ? {
  582. cache: {
  583. read: model.cost.context_over_200k.cache_read ?? 0,
  584. write: model.cost.context_over_200k.cache_write ?? 0,
  585. },
  586. input: model.cost.context_over_200k.input,
  587. output: model.cost.context_over_200k.output,
  588. }
  589. : undefined,
  590. },
  591. limit: {
  592. context: model.limit.context,
  593. input: model.limit.input,
  594. output: model.limit.output,
  595. },
  596. capabilities: {
  597. temperature: model.temperature,
  598. reasoning: model.reasoning,
  599. attachment: model.attachment,
  600. toolcall: model.tool_call,
  601. input: {
  602. text: model.modalities?.input?.includes("text") ?? false,
  603. audio: model.modalities?.input?.includes("audio") ?? false,
  604. image: model.modalities?.input?.includes("image") ?? false,
  605. video: model.modalities?.input?.includes("video") ?? false,
  606. pdf: model.modalities?.input?.includes("pdf") ?? false,
  607. },
  608. output: {
  609. text: model.modalities?.output?.includes("text") ?? false,
  610. audio: model.modalities?.output?.includes("audio") ?? false,
  611. image: model.modalities?.output?.includes("image") ?? false,
  612. video: model.modalities?.output?.includes("video") ?? false,
  613. pdf: model.modalities?.output?.includes("pdf") ?? false,
  614. },
  615. interleaved: model.interleaved ?? false,
  616. },
  617. release_date: model.release_date,
  618. variants: {},
  619. }
  620. m.variants = mapValues(ProviderTransform.variants(m), (v) => v)
  621. return m
  622. }
  623. export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
  624. return {
  625. id: provider.id,
  626. source: "custom",
  627. name: provider.name,
  628. env: provider.env ?? [],
  629. options: {},
  630. models: mapValues(provider.models, (model) => fromModelsDevModel(provider, model)),
  631. }
  632. }
  633. const state = Instance.state(async () => {
  634. using _ = log.time("state")
  635. const config = await Config.get()
  636. const modelsDev = await ModelsDev.get()
  637. const database = mapValues(modelsDev, fromModelsDevProvider)
  638. const disabled = new Set(config.disabled_providers ?? [])
  639. const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null
  640. function isProviderAllowed(providerID: string): boolean {
  641. if (enabled && !enabled.has(providerID)) return false
  642. if (disabled.has(providerID)) return false
  643. return true
  644. }
  645. const providers: { [providerID: string]: Info } = {}
  646. const languages = new Map<string, LanguageModelV2>()
  647. const modelLoaders: {
  648. [providerID: string]: CustomModelLoader
  649. } = {}
  650. const sdk = new Map<number, SDK>()
  651. log.info("init")
  652. const configProviders = Object.entries(config.provider ?? {})
  653. // Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot
  654. if (database["github-copilot"]) {
  655. const githubCopilot = database["github-copilot"]
  656. database["github-copilot-enterprise"] = {
  657. ...githubCopilot,
  658. id: "github-copilot-enterprise",
  659. name: "GitHub Copilot Enterprise",
  660. models: mapValues(githubCopilot.models, (model) => ({
  661. ...model,
  662. providerID: "github-copilot-enterprise",
  663. })),
  664. }
  665. }
  666. function mergeProvider(providerID: string, provider: Partial<Info>) {
  667. const existing = providers[providerID]
  668. if (existing) {
  669. // @ts-expect-error
  670. providers[providerID] = mergeDeep(existing, provider)
  671. return
  672. }
  673. const match = database[providerID]
  674. if (!match) return
  675. // @ts-expect-error
  676. providers[providerID] = mergeDeep(match, provider)
  677. }
  678. // extend database from config
  679. for (const [providerID, provider] of configProviders) {
  680. const existing = database[providerID]
  681. const parsed: Info = {
  682. id: providerID,
  683. name: provider.name ?? existing?.name ?? providerID,
  684. env: provider.env ?? existing?.env ?? [],
  685. options: mergeDeep(existing?.options ?? {}, provider.options ?? {}),
  686. source: "config",
  687. models: existing?.models ?? {},
  688. }
  689. for (const [modelID, model] of Object.entries(provider.models ?? {})) {
  690. const existingModel = parsed.models[model.id ?? modelID]
  691. const name = iife(() => {
  692. if (model.name) return model.name
  693. if (model.id && model.id !== modelID) return modelID
  694. return existingModel?.name ?? modelID
  695. })
  696. const parsedModel: Model = {
  697. id: modelID,
  698. api: {
  699. id: model.id ?? existingModel?.api.id ?? modelID,
  700. npm:
  701. model.provider?.npm ??
  702. provider.npm ??
  703. existingModel?.api.npm ??
  704. modelsDev[providerID]?.npm ??
  705. "@ai-sdk/openai-compatible",
  706. url: provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api,
  707. },
  708. status: model.status ?? existingModel?.status ?? "active",
  709. name,
  710. providerID,
  711. capabilities: {
  712. temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false,
  713. reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false,
  714. attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false,
  715. toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true,
  716. input: {
  717. text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true,
  718. audio: model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false,
  719. image: model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false,
  720. video: model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false,
  721. pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false,
  722. },
  723. output: {
  724. text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true,
  725. audio: model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? false,
  726. image: model.modalities?.output?.includes("image") ?? existingModel?.capabilities.output.image ?? false,
  727. video: model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false,
  728. pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
  729. },
  730. interleaved: model.interleaved ?? false,
  731. },
  732. cost: {
  733. input: model?.cost?.input ?? existingModel?.cost?.input ?? 0,
  734. output: model?.cost?.output ?? existingModel?.cost?.output ?? 0,
  735. cache: {
  736. read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0,
  737. write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0,
  738. },
  739. },
  740. options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}),
  741. limit: {
  742. context: model.limit?.context ?? existingModel?.limit?.context ?? 0,
  743. output: model.limit?.output ?? existingModel?.limit?.output ?? 0,
  744. },
  745. headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
  746. family: model.family ?? existingModel?.family ?? "",
  747. release_date: model.release_date ?? existingModel?.release_date ?? "",
  748. variants: {},
  749. }
  750. const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
  751. parsedModel.variants = mapValues(
  752. pickBy(merged, (v) => !v.disabled),
  753. (v) => omit(v, ["disabled"]),
  754. )
  755. parsed.models[modelID] = parsedModel
  756. }
  757. database[providerID] = parsed
  758. }
  759. // load env
  760. const env = Env.all()
  761. for (const [providerID, provider] of Object.entries(database)) {
  762. if (disabled.has(providerID)) continue
  763. const apiKey = provider.env.map((item) => env[item]).find(Boolean)
  764. if (!apiKey) continue
  765. mergeProvider(providerID, {
  766. source: "env",
  767. key: provider.env.length === 1 ? apiKey : undefined,
  768. })
  769. }
  770. // load apikeys
  771. for (const [providerID, provider] of Object.entries(await Auth.all())) {
  772. if (disabled.has(providerID)) continue
  773. if (provider.type === "api") {
  774. mergeProvider(providerID, {
  775. source: "api",
  776. key: provider.key,
  777. })
  778. }
  779. }
  780. for (const plugin of await Plugin.list()) {
  781. if (!plugin.auth) continue
  782. const providerID = plugin.auth.provider
  783. if (disabled.has(providerID)) continue
  784. // For github-copilot plugin, check if auth exists for either github-copilot or github-copilot-enterprise
  785. let hasAuth = false
  786. const auth = await Auth.get(providerID)
  787. if (auth) hasAuth = true
  788. // Special handling for github-copilot: also check for enterprise auth
  789. if (providerID === "github-copilot" && !hasAuth) {
  790. const enterpriseAuth = await Auth.get("github-copilot-enterprise")
  791. if (enterpriseAuth) hasAuth = true
  792. }
  793. if (!hasAuth) continue
  794. if (!plugin.auth.loader) continue
  795. // Load for the main provider if auth exists
  796. if (auth) {
  797. const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider])
  798. const opts = options ?? {}
  799. const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
  800. mergeProvider(providerID, patch)
  801. }
  802. // If this is github-copilot plugin, also register for github-copilot-enterprise if auth exists
  803. if (providerID === "github-copilot") {
  804. const enterpriseProviderID = "github-copilot-enterprise"
  805. if (!disabled.has(enterpriseProviderID)) {
  806. const enterpriseAuth = await Auth.get(enterpriseProviderID)
  807. if (enterpriseAuth) {
  808. const enterpriseOptions = await plugin.auth.loader(
  809. () => Auth.get(enterpriseProviderID) as any,
  810. database[enterpriseProviderID],
  811. )
  812. const opts = enterpriseOptions ?? {}
  813. const patch: Partial<Info> = providers[enterpriseProviderID]
  814. ? { options: opts }
  815. : { source: "custom", options: opts }
  816. mergeProvider(enterpriseProviderID, patch)
  817. }
  818. }
  819. }
  820. }
  821. for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
  822. if (disabled.has(providerID)) continue
  823. const data = database[providerID]
  824. if (!data) {
  825. log.error("Provider does not exist in model list " + providerID)
  826. continue
  827. }
  828. const result = await fn(data)
  829. if (result && (result.autoload || providers[providerID])) {
  830. if (result.getModel) modelLoaders[providerID] = result.getModel
  831. const opts = result.options ?? {}
  832. const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
  833. mergeProvider(providerID, patch)
  834. }
  835. }
  836. // load config
  837. for (const [providerID, provider] of configProviders) {
  838. const partial: Partial<Info> = { source: "config" }
  839. if (provider.env) partial.env = provider.env
  840. if (provider.name) partial.name = provider.name
  841. if (provider.options) partial.options = provider.options
  842. mergeProvider(providerID, partial)
  843. }
  844. for (const [providerID, provider] of Object.entries(providers)) {
  845. if (!isProviderAllowed(providerID)) {
  846. delete providers[providerID]
  847. continue
  848. }
  849. const configProvider = config.provider?.[providerID]
  850. for (const [modelID, model] of Object.entries(provider.models)) {
  851. model.api.id = model.api.id ?? model.id ?? modelID
  852. if (modelID === "gpt-5-chat-latest" || (providerID === "openrouter" && modelID === "openai/gpt-5-chat"))
  853. delete provider.models[modelID]
  854. if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) delete provider.models[modelID]
  855. if (model.status === "deprecated") delete provider.models[modelID]
  856. if (
  857. (configProvider?.blacklist && configProvider.blacklist.includes(modelID)) ||
  858. (configProvider?.whitelist && !configProvider.whitelist.includes(modelID))
  859. )
  860. delete provider.models[modelID]
  861. model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
  862. // Filter out disabled variants from config
  863. const configVariants = configProvider?.models?.[modelID]?.variants
  864. if (configVariants && model.variants) {
  865. const merged = mergeDeep(model.variants, configVariants)
  866. model.variants = mapValues(
  867. pickBy(merged, (v) => !v.disabled),
  868. (v) => omit(v, ["disabled"]),
  869. )
  870. }
  871. }
  872. if (Object.keys(provider.models).length === 0) {
  873. delete providers[providerID]
  874. continue
  875. }
  876. log.info("found", { providerID })
  877. }
  878. return {
  879. models: languages,
  880. providers,
  881. sdk,
  882. modelLoaders,
  883. }
  884. })
  885. export async function list() {
  886. return state().then((state) => state.providers)
  887. }
  888. async function getSDK(model: Model) {
  889. try {
  890. using _ = log.time("getSDK", {
  891. providerID: model.providerID,
  892. })
  893. const s = await state()
  894. const provider = s.providers[model.providerID]
  895. const options = { ...provider.options }
  896. if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) {
  897. options["includeUsage"] = true
  898. }
  899. if (!options["baseURL"]) options["baseURL"] = model.api.url
  900. if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key
  901. if (model.headers)
  902. options["headers"] = {
  903. ...options["headers"],
  904. ...model.headers,
  905. }
  906. const key = Bun.hash.xxHash32(JSON.stringify({ providerID: model.providerID, npm: model.api.npm, options }))
  907. const existing = s.sdk.get(key)
  908. if (existing) return existing
  909. const customFetch = options["fetch"]
  910. options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
  911. // Preserve custom fetch if it exists, wrap it with timeout logic
  912. const fetchFn = customFetch ?? fetch
  913. const opts = init ?? {}
  914. if (options["timeout"] !== undefined && options["timeout"] !== null) {
  915. const signals: AbortSignal[] = []
  916. if (opts.signal) signals.push(opts.signal)
  917. if (options["timeout"] !== false) signals.push(AbortSignal.timeout(options["timeout"]))
  918. const combined = signals.length > 1 ? AbortSignal.any(signals) : signals[0]
  919. opts.signal = combined
  920. }
  921. // Strip openai itemId metadata following what codex does
  922. // Codex uses #[serde(skip_serializing)] on id fields for all item types:
  923. // Message, Reasoning, FunctionCall, LocalShellCall, CustomToolCall, WebSearchCall
  924. // IDs are only re-attached for Azure with store=true
  925. if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") {
  926. const body = JSON.parse(opts.body as string)
  927. const isAzure = model.providerID.includes("azure")
  928. const keepIds = isAzure && body.store === true
  929. if (!keepIds && Array.isArray(body.input)) {
  930. for (const item of body.input) {
  931. if ("id" in item) {
  932. delete item.id
  933. }
  934. }
  935. opts.body = JSON.stringify(body)
  936. }
  937. }
  938. return fetchFn(input, {
  939. ...opts,
  940. // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
  941. timeout: false,
  942. })
  943. }
  944. // Special case: google-vertex-anthropic uses a subpath import
  945. const bundledKey =
  946. model.providerID === "google-vertex-anthropic" ? "@ai-sdk/google-vertex/anthropic" : model.api.npm
  947. const bundledFn = BUNDLED_PROVIDERS[bundledKey]
  948. if (bundledFn) {
  949. log.info("using bundled provider", { providerID: model.providerID, pkg: bundledKey })
  950. const loaded = bundledFn({
  951. name: model.providerID,
  952. ...options,
  953. })
  954. s.sdk.set(key, loaded)
  955. return loaded as SDK
  956. }
  957. let installedPath: string
  958. if (!model.api.npm.startsWith("file://")) {
  959. installedPath = await BunProc.install(model.api.npm, "latest")
  960. } else {
  961. log.info("loading local provider", { pkg: model.api.npm })
  962. installedPath = model.api.npm
  963. }
  964. const mod = await import(installedPath)
  965. const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
  966. const loaded = fn({
  967. name: model.providerID,
  968. ...options,
  969. })
  970. s.sdk.set(key, loaded)
  971. return loaded as SDK
  972. } catch (e) {
  973. throw new InitError({ providerID: model.providerID }, { cause: e })
  974. }
  975. }
  976. export async function getProvider(providerID: string) {
  977. return state().then((s) => s.providers[providerID])
  978. }
  979. export async function getModel(providerID: string, modelID: string) {
  980. const s = await state()
  981. const provider = s.providers[providerID]
  982. if (!provider) {
  983. const availableProviders = Object.keys(s.providers)
  984. const matches = fuzzysort.go(providerID, availableProviders, { limit: 3, threshold: -10000 })
  985. const suggestions = matches.map((m) => m.target)
  986. throw new ModelNotFoundError({ providerID, modelID, suggestions })
  987. }
  988. const info = provider.models[modelID]
  989. if (!info) {
  990. const availableModels = Object.keys(provider.models)
  991. const matches = fuzzysort.go(modelID, availableModels, { limit: 3, threshold: -10000 })
  992. const suggestions = matches.map((m) => m.target)
  993. throw new ModelNotFoundError({ providerID, modelID, suggestions })
  994. }
  995. return info
  996. }
  997. export async function getLanguage(model: Model): Promise<LanguageModelV2> {
  998. const s = await state()
  999. const key = `${model.providerID}/${model.id}`
  1000. if (s.models.has(key)) return s.models.get(key)!
  1001. const provider = s.providers[model.providerID]
  1002. const sdk = await getSDK(model)
  1003. try {
  1004. const language = s.modelLoaders[model.providerID]
  1005. ? await s.modelLoaders[model.providerID](sdk, model.api.id, provider.options)
  1006. : sdk.languageModel(model.api.id)
  1007. s.models.set(key, language)
  1008. return language
  1009. } catch (e) {
  1010. if (e instanceof NoSuchModelError)
  1011. throw new ModelNotFoundError(
  1012. {
  1013. modelID: model.id,
  1014. providerID: model.providerID,
  1015. },
  1016. { cause: e },
  1017. )
  1018. throw e
  1019. }
  1020. }
  1021. export async function closest(providerID: string, query: string[]) {
  1022. const s = await state()
  1023. const provider = s.providers[providerID]
  1024. if (!provider) return undefined
  1025. for (const item of query) {
  1026. for (const modelID of Object.keys(provider.models)) {
  1027. if (modelID.includes(item))
  1028. return {
  1029. providerID,
  1030. modelID,
  1031. }
  1032. }
  1033. }
  1034. }
  1035. export async function getSmallModel(providerID: string) {
  1036. const cfg = await Config.get()
  1037. if (cfg.small_model) {
  1038. const parsed = parseModel(cfg.small_model)
  1039. return getModel(parsed.providerID, parsed.modelID)
  1040. }
  1041. const provider = await state().then((state) => state.providers[providerID])
  1042. if (provider) {
  1043. let priority = [
  1044. "claude-haiku-4-5",
  1045. "claude-haiku-4.5",
  1046. "3-5-haiku",
  1047. "3.5-haiku",
  1048. "gemini-3-flash",
  1049. "gemini-2.5-flash",
  1050. "gpt-5-nano",
  1051. ]
  1052. if (providerID.startsWith("opencode")) {
  1053. priority = ["gpt-5-nano"]
  1054. }
  1055. if (providerID.startsWith("github-copilot")) {
  1056. // prioritize free models for github copilot
  1057. priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority]
  1058. }
  1059. for (const item of priority) {
  1060. for (const model of Object.keys(provider.models)) {
  1061. if (model.includes(item)) return getModel(providerID, model)
  1062. }
  1063. }
  1064. }
  1065. // Check if opencode provider is available before using it
  1066. const opencodeProvider = await state().then((state) => state.providers["opencode"])
  1067. if (opencodeProvider && opencodeProvider.models["gpt-5-nano"]) {
  1068. return getModel("opencode", "gpt-5-nano")
  1069. }
  1070. return undefined
  1071. }
  1072. const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
  1073. export function sort(models: Model[]) {
  1074. return sortBy(
  1075. models,
  1076. [(model) => priority.findIndex((filter) => model.id.includes(filter)), "desc"],
  1077. [(model) => (model.id.includes("latest") ? 0 : 1), "asc"],
  1078. [(model) => model.id, "desc"],
  1079. )
  1080. }
  1081. export async function defaultModel() {
  1082. const cfg = await Config.get()
  1083. if (cfg.model) return parseModel(cfg.model)
  1084. const provider = await list()
  1085. .then((val) => Object.values(val))
  1086. .then((x) => x.find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id)))
  1087. if (!provider) throw new Error("no providers found")
  1088. const [model] = sort(Object.values(provider.models))
  1089. if (!model) throw new Error("no models found")
  1090. return {
  1091. providerID: provider.id,
  1092. modelID: model.id,
  1093. }
  1094. }
  1095. export function parseModel(model: string) {
  1096. const [providerID, ...rest] = model.split("/")
  1097. return {
  1098. providerID: providerID,
  1099. modelID: rest.join("/"),
  1100. }
  1101. }
  1102. export const ModelNotFoundError = NamedError.create(
  1103. "ProviderModelNotFoundError",
  1104. z.object({
  1105. providerID: z.string(),
  1106. modelID: z.string(),
  1107. suggestions: z.array(z.string()).optional(),
  1108. }),
  1109. )
  1110. export const InitError = NamedError.create(
  1111. "ProviderInitError",
  1112. z.object({
  1113. providerID: z.string(),
  1114. }),
  1115. )
  1116. }