provider.ts 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218
  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. return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
  127. },
  128. options: {},
  129. }
  130. },
  131. "github-copilot-enterprise": async () => {
  132. return {
  133. autoload: false,
  134. async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
  135. return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
  136. },
  137. options: {},
  138. }
  139. },
  140. azure: async () => {
  141. return {
  142. autoload: false,
  143. async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
  144. if (options?.["useCompletionUrls"]) {
  145. return sdk.chat(modelID)
  146. } else {
  147. return sdk.responses(modelID)
  148. }
  149. },
  150. options: {},
  151. }
  152. },
  153. "azure-cognitive-services": async () => {
  154. const resourceName = Env.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
  155. return {
  156. autoload: false,
  157. async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
  158. if (options?.["useCompletionUrls"]) {
  159. return sdk.chat(modelID)
  160. } else {
  161. return sdk.responses(modelID)
  162. }
  163. },
  164. options: {
  165. baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
  166. },
  167. }
  168. },
  169. "amazon-bedrock": async () => {
  170. const config = await Config.get()
  171. const providerConfig = config.provider?.["amazon-bedrock"]
  172. const auth = await Auth.get("amazon-bedrock")
  173. // Region precedence: 1) config file, 2) env var, 3) default
  174. const configRegion = providerConfig?.options?.region
  175. const envRegion = Env.get("AWS_REGION")
  176. const defaultRegion = configRegion ?? envRegion ?? "us-east-1"
  177. // Profile: config file takes precedence over env var
  178. const configProfile = providerConfig?.options?.profile
  179. const envProfile = Env.get("AWS_PROFILE")
  180. const profile = configProfile ?? envProfile
  181. const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID")
  182. const awsBearerToken = iife(() => {
  183. const envToken = Env.get("AWS_BEARER_TOKEN_BEDROCK")
  184. if (envToken) return envToken
  185. if (auth?.type === "api") {
  186. Env.set("AWS_BEARER_TOKEN_BEDROCK", auth.key)
  187. return auth.key
  188. }
  189. return undefined
  190. })
  191. const awsWebIdentityTokenFile = Env.get("AWS_WEB_IDENTITY_TOKEN_FILE")
  192. if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile) return { autoload: false }
  193. const providerOptions: AmazonBedrockProviderSettings = {
  194. region: defaultRegion,
  195. }
  196. // Only use credential chain if no bearer token exists
  197. // Bearer token takes precedence over credential chain (profiles, access keys, IAM roles, web identity tokens)
  198. if (!awsBearerToken) {
  199. const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
  200. // Build credential provider options (only pass profile if specified)
  201. const credentialProviderOptions = profile ? { profile } : {}
  202. providerOptions.credentialProvider = fromNodeProviderChain(credentialProviderOptions)
  203. }
  204. // Add custom endpoint if specified (endpoint takes precedence over baseURL)
  205. const endpoint = providerConfig?.options?.endpoint ?? providerConfig?.options?.baseURL
  206. if (endpoint) {
  207. providerOptions.baseURL = endpoint
  208. }
  209. return {
  210. autoload: true,
  211. options: providerOptions,
  212. async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
  213. // Skip region prefixing if model already has a cross-region inference profile prefix
  214. if (modelID.startsWith("global.") || modelID.startsWith("jp.")) {
  215. return sdk.languageModel(modelID)
  216. }
  217. // Region resolution precedence (highest to lowest):
  218. // 1. options.region from opencode.json provider config
  219. // 2. defaultRegion from AWS_REGION environment variable
  220. // 3. Default "us-east-1" (baked into defaultRegion)
  221. const region = options?.region ?? defaultRegion
  222. let regionPrefix = region.split("-")[0]
  223. switch (regionPrefix) {
  224. case "us": {
  225. const modelRequiresPrefix = [
  226. "nova-micro",
  227. "nova-lite",
  228. "nova-pro",
  229. "nova-premier",
  230. "nova-2",
  231. "claude",
  232. "deepseek",
  233. ].some((m) => modelID.includes(m))
  234. const isGovCloud = region.startsWith("us-gov")
  235. if (modelRequiresPrefix && !isGovCloud) {
  236. modelID = `${regionPrefix}.${modelID}`
  237. }
  238. break
  239. }
  240. case "eu": {
  241. const regionRequiresPrefix = [
  242. "eu-west-1",
  243. "eu-west-2",
  244. "eu-west-3",
  245. "eu-north-1",
  246. "eu-central-1",
  247. "eu-south-1",
  248. "eu-south-2",
  249. ].some((r) => region.includes(r))
  250. const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((m) =>
  251. modelID.includes(m),
  252. )
  253. if (regionRequiresPrefix && modelRequiresPrefix) {
  254. modelID = `${regionPrefix}.${modelID}`
  255. }
  256. break
  257. }
  258. case "ap": {
  259. const isAustraliaRegion = ["ap-southeast-2", "ap-southeast-4"].includes(region)
  260. const isTokyoRegion = region === "ap-northeast-1"
  261. if (
  262. isAustraliaRegion &&
  263. ["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((m) => modelID.includes(m))
  264. ) {
  265. regionPrefix = "au"
  266. modelID = `${regionPrefix}.${modelID}`
  267. } else if (isTokyoRegion) {
  268. // Tokyo region uses jp. prefix for cross-region inference
  269. const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) =>
  270. modelID.includes(m),
  271. )
  272. if (modelRequiresPrefix) {
  273. regionPrefix = "jp"
  274. modelID = `${regionPrefix}.${modelID}`
  275. }
  276. } else {
  277. // Other APAC regions use apac. prefix
  278. const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) =>
  279. modelID.includes(m),
  280. )
  281. if (modelRequiresPrefix) {
  282. regionPrefix = "apac"
  283. modelID = `${regionPrefix}.${modelID}`
  284. }
  285. }
  286. break
  287. }
  288. }
  289. return sdk.languageModel(modelID)
  290. },
  291. }
  292. },
  293. openrouter: async () => {
  294. return {
  295. autoload: false,
  296. options: {
  297. headers: {
  298. "HTTP-Referer": "https://opencode.ai/",
  299. "X-Title": "opencode",
  300. },
  301. },
  302. }
  303. },
  304. vercel: async () => {
  305. return {
  306. autoload: false,
  307. options: {
  308. headers: {
  309. "http-referer": "https://opencode.ai/",
  310. "x-title": "opencode",
  311. },
  312. },
  313. }
  314. },
  315. "google-vertex": async () => {
  316. const project = Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
  317. const location = Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-east5"
  318. const autoload = Boolean(project)
  319. if (!autoload) return { autoload: false }
  320. return {
  321. autoload: true,
  322. options: {
  323. project,
  324. location,
  325. },
  326. async getModel(sdk: any, modelID: string) {
  327. const id = String(modelID).trim()
  328. return sdk.languageModel(id)
  329. },
  330. }
  331. },
  332. "google-vertex-anthropic": async () => {
  333. const project = Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
  334. const location = Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "global"
  335. const autoload = Boolean(project)
  336. if (!autoload) return { autoload: false }
  337. return {
  338. autoload: true,
  339. options: {
  340. project,
  341. location,
  342. },
  343. async getModel(sdk: any, modelID) {
  344. const id = String(modelID).trim()
  345. return sdk.languageModel(id)
  346. },
  347. }
  348. },
  349. "sap-ai-core": async () => {
  350. const auth = await Auth.get("sap-ai-core")
  351. const envServiceKey = iife(() => {
  352. const envAICoreServiceKey = Env.get("AICORE_SERVICE_KEY")
  353. if (envAICoreServiceKey) return envAICoreServiceKey
  354. if (auth?.type === "api") {
  355. Env.set("AICORE_SERVICE_KEY", auth.key)
  356. return auth.key
  357. }
  358. return undefined
  359. })
  360. const deploymentId = Env.get("AICORE_DEPLOYMENT_ID")
  361. const resourceGroup = Env.get("AICORE_RESOURCE_GROUP")
  362. return {
  363. autoload: !!envServiceKey,
  364. options: envServiceKey ? { deploymentId, resourceGroup } : {},
  365. async getModel(sdk: any, modelID: string) {
  366. return sdk(modelID)
  367. },
  368. }
  369. },
  370. zenmux: async () => {
  371. return {
  372. autoload: false,
  373. options: {
  374. headers: {
  375. "HTTP-Referer": "https://opencode.ai/",
  376. "X-Title": "opencode",
  377. },
  378. },
  379. }
  380. },
  381. gitlab: async (input) => {
  382. const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com"
  383. const auth = await Auth.get(input.id)
  384. const apiKey = await (async () => {
  385. if (auth?.type === "oauth") return auth.access
  386. if (auth?.type === "api") return auth.key
  387. return Env.get("GITLAB_TOKEN")
  388. })()
  389. const config = await Config.get()
  390. const providerConfig = config.provider?.["gitlab"]
  391. return {
  392. autoload: !!apiKey,
  393. options: {
  394. instanceUrl,
  395. apiKey,
  396. featureFlags: {
  397. duo_agent_platform_agentic_chat: true,
  398. duo_agent_platform: true,
  399. ...(providerConfig?.options?.featureFlags || {}),
  400. },
  401. },
  402. async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string) {
  403. return sdk.agenticChat(modelID, {
  404. featureFlags: {
  405. duo_agent_platform_agentic_chat: true,
  406. duo_agent_platform: true,
  407. ...(providerConfig?.options?.featureFlags || {}),
  408. },
  409. })
  410. },
  411. }
  412. },
  413. "cloudflare-ai-gateway": async (input) => {
  414. const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
  415. const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")
  416. if (!accountId || !gateway) return { autoload: false }
  417. // Get API token from env or auth prompt
  418. const apiToken = await (async () => {
  419. const envToken = Env.get("CLOUDFLARE_API_TOKEN")
  420. if (envToken) return envToken
  421. const auth = await Auth.get(input.id)
  422. if (auth?.type === "api") return auth.key
  423. return undefined
  424. })()
  425. return {
  426. autoload: true,
  427. async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
  428. return sdk.languageModel(modelID)
  429. },
  430. options: {
  431. baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gateway}/compat`,
  432. headers: {
  433. // Cloudflare AI Gateway uses cf-aig-authorization for authenticated gateways
  434. // This enables Unified Billing where Cloudflare handles upstream provider auth
  435. ...(apiToken ? { "cf-aig-authorization": `Bearer ${apiToken}` } : {}),
  436. "HTTP-Referer": "https://opencode.ai/",
  437. "X-Title": "opencode",
  438. },
  439. // Custom fetch to handle parameter transformation and auth
  440. fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
  441. const headers = new Headers(init?.headers)
  442. // Strip Authorization header - AI Gateway uses cf-aig-authorization instead
  443. headers.delete("Authorization")
  444. // Transform max_tokens to max_completion_tokens for newer models
  445. if (init?.body && init.method === "POST") {
  446. try {
  447. const body = JSON.parse(init.body as string)
  448. if (body.max_tokens !== undefined && !body.max_completion_tokens) {
  449. body.max_completion_tokens = body.max_tokens
  450. delete body.max_tokens
  451. init = { ...init, body: JSON.stringify(body) }
  452. }
  453. } catch (e) {
  454. // If body parsing fails, continue with original request
  455. }
  456. }
  457. return fetch(input, { ...init, headers })
  458. },
  459. },
  460. }
  461. },
  462. cerebras: async () => {
  463. return {
  464. autoload: false,
  465. options: {
  466. headers: {
  467. "X-Cerebras-3rd-Party-Integration": "opencode",
  468. },
  469. },
  470. }
  471. },
  472. }
  473. export const Model = z
  474. .object({
  475. id: z.string(),
  476. providerID: z.string(),
  477. api: z.object({
  478. id: z.string(),
  479. url: z.string(),
  480. npm: z.string(),
  481. }),
  482. name: z.string(),
  483. family: z.string().optional(),
  484. capabilities: z.object({
  485. temperature: z.boolean(),
  486. reasoning: z.boolean(),
  487. attachment: z.boolean(),
  488. toolcall: z.boolean(),
  489. input: z.object({
  490. text: z.boolean(),
  491. audio: z.boolean(),
  492. image: z.boolean(),
  493. video: z.boolean(),
  494. pdf: z.boolean(),
  495. }),
  496. output: z.object({
  497. text: z.boolean(),
  498. audio: z.boolean(),
  499. image: z.boolean(),
  500. video: z.boolean(),
  501. pdf: z.boolean(),
  502. }),
  503. interleaved: z.union([
  504. z.boolean(),
  505. z.object({
  506. field: z.enum(["reasoning_content", "reasoning_details"]),
  507. }),
  508. ]),
  509. }),
  510. cost: z.object({
  511. input: z.number(),
  512. output: z.number(),
  513. cache: z.object({
  514. read: z.number(),
  515. write: z.number(),
  516. }),
  517. experimentalOver200K: z
  518. .object({
  519. input: z.number(),
  520. output: z.number(),
  521. cache: z.object({
  522. read: z.number(),
  523. write: z.number(),
  524. }),
  525. })
  526. .optional(),
  527. }),
  528. limit: z.object({
  529. context: z.number(),
  530. input: z.number().optional(),
  531. output: z.number(),
  532. }),
  533. status: z.enum(["alpha", "beta", "deprecated", "active"]),
  534. options: z.record(z.string(), z.any()),
  535. headers: z.record(z.string(), z.string()),
  536. release_date: z.string(),
  537. variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
  538. })
  539. .meta({
  540. ref: "Model",
  541. })
  542. export type Model = z.infer<typeof Model>
  543. export const Info = z
  544. .object({
  545. id: z.string(),
  546. name: z.string(),
  547. source: z.enum(["env", "config", "custom", "api"]),
  548. env: z.string().array(),
  549. key: z.string().optional(),
  550. options: z.record(z.string(), z.any()),
  551. models: z.record(z.string(), Model),
  552. })
  553. .meta({
  554. ref: "Provider",
  555. })
  556. export type Info = z.infer<typeof Info>
  557. function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
  558. const m: Model = {
  559. id: model.id,
  560. providerID: provider.id,
  561. name: model.name,
  562. family: model.family,
  563. api: {
  564. id: model.id,
  565. url: provider.api!,
  566. npm: iife(() => {
  567. if (provider.id.startsWith("github-copilot")) return "@ai-sdk/github-copilot"
  568. return model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible"
  569. }),
  570. },
  571. status: model.status ?? "active",
  572. headers: model.headers ?? {},
  573. options: model.options ?? {},
  574. cost: {
  575. input: model.cost?.input ?? 0,
  576. output: model.cost?.output ?? 0,
  577. cache: {
  578. read: model.cost?.cache_read ?? 0,
  579. write: model.cost?.cache_write ?? 0,
  580. },
  581. experimentalOver200K: model.cost?.context_over_200k
  582. ? {
  583. cache: {
  584. read: model.cost.context_over_200k.cache_read ?? 0,
  585. write: model.cost.context_over_200k.cache_write ?? 0,
  586. },
  587. input: model.cost.context_over_200k.input,
  588. output: model.cost.context_over_200k.output,
  589. }
  590. : undefined,
  591. },
  592. limit: {
  593. context: model.limit.context,
  594. input: model.limit.input,
  595. output: model.limit.output,
  596. },
  597. capabilities: {
  598. temperature: model.temperature,
  599. reasoning: model.reasoning,
  600. attachment: model.attachment,
  601. toolcall: model.tool_call,
  602. input: {
  603. text: model.modalities?.input?.includes("text") ?? false,
  604. audio: model.modalities?.input?.includes("audio") ?? false,
  605. image: model.modalities?.input?.includes("image") ?? false,
  606. video: model.modalities?.input?.includes("video") ?? false,
  607. pdf: model.modalities?.input?.includes("pdf") ?? false,
  608. },
  609. output: {
  610. text: model.modalities?.output?.includes("text") ?? false,
  611. audio: model.modalities?.output?.includes("audio") ?? false,
  612. image: model.modalities?.output?.includes("image") ?? false,
  613. video: model.modalities?.output?.includes("video") ?? false,
  614. pdf: model.modalities?.output?.includes("pdf") ?? false,
  615. },
  616. interleaved: model.interleaved ?? false,
  617. },
  618. release_date: model.release_date,
  619. variants: {},
  620. }
  621. m.variants = mapValues(ProviderTransform.variants(m), (v) => v)
  622. return m
  623. }
  624. export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
  625. return {
  626. id: provider.id,
  627. source: "custom",
  628. name: provider.name,
  629. env: provider.env ?? [],
  630. options: {},
  631. models: mapValues(provider.models, (model) => fromModelsDevModel(provider, model)),
  632. }
  633. }
  634. const state = Instance.state(async () => {
  635. using _ = log.time("state")
  636. const config = await Config.get()
  637. const modelsDev = await ModelsDev.get()
  638. const database = mapValues(modelsDev, fromModelsDevProvider)
  639. const disabled = new Set(config.disabled_providers ?? [])
  640. const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null
  641. function isProviderAllowed(providerID: string): boolean {
  642. if (enabled && !enabled.has(providerID)) return false
  643. if (disabled.has(providerID)) return false
  644. return true
  645. }
  646. const providers: { [providerID: string]: Info } = {}
  647. const languages = new Map<string, LanguageModelV2>()
  648. const modelLoaders: {
  649. [providerID: string]: CustomModelLoader
  650. } = {}
  651. const sdk = new Map<number, SDK>()
  652. log.info("init")
  653. const configProviders = Object.entries(config.provider ?? {})
  654. // Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot
  655. if (database["github-copilot"]) {
  656. const githubCopilot = database["github-copilot"]
  657. database["github-copilot-enterprise"] = {
  658. ...githubCopilot,
  659. id: "github-copilot-enterprise",
  660. name: "GitHub Copilot Enterprise",
  661. models: mapValues(githubCopilot.models, (model) => ({
  662. ...model,
  663. providerID: "github-copilot-enterprise",
  664. })),
  665. }
  666. }
  667. function mergeProvider(providerID: string, provider: Partial<Info>) {
  668. const existing = providers[providerID]
  669. if (existing) {
  670. // @ts-expect-error
  671. providers[providerID] = mergeDeep(existing, provider)
  672. return
  673. }
  674. const match = database[providerID]
  675. if (!match) return
  676. // @ts-expect-error
  677. providers[providerID] = mergeDeep(match, provider)
  678. }
  679. // extend database from config
  680. for (const [providerID, provider] of configProviders) {
  681. const existing = database[providerID]
  682. const parsed: Info = {
  683. id: providerID,
  684. name: provider.name ?? existing?.name ?? providerID,
  685. env: provider.env ?? existing?.env ?? [],
  686. options: mergeDeep(existing?.options ?? {}, provider.options ?? {}),
  687. source: "config",
  688. models: existing?.models ?? {},
  689. }
  690. for (const [modelID, model] of Object.entries(provider.models ?? {})) {
  691. const existingModel = parsed.models[model.id ?? modelID]
  692. const name = iife(() => {
  693. if (model.name) return model.name
  694. if (model.id && model.id !== modelID) return modelID
  695. return existingModel?.name ?? modelID
  696. })
  697. const parsedModel: Model = {
  698. id: modelID,
  699. api: {
  700. id: model.id ?? existingModel?.api.id ?? modelID,
  701. npm:
  702. model.provider?.npm ??
  703. provider.npm ??
  704. existingModel?.api.npm ??
  705. modelsDev[providerID]?.npm ??
  706. "@ai-sdk/openai-compatible",
  707. url: provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api,
  708. },
  709. status: model.status ?? existingModel?.status ?? "active",
  710. name,
  711. providerID,
  712. capabilities: {
  713. temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false,
  714. reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false,
  715. attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false,
  716. toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true,
  717. input: {
  718. text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true,
  719. audio: model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false,
  720. image: model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false,
  721. video: model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false,
  722. pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false,
  723. },
  724. output: {
  725. text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true,
  726. audio: model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? false,
  727. image: model.modalities?.output?.includes("image") ?? existingModel?.capabilities.output.image ?? false,
  728. video: model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false,
  729. pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
  730. },
  731. interleaved: model.interleaved ?? false,
  732. },
  733. cost: {
  734. input: model?.cost?.input ?? existingModel?.cost?.input ?? 0,
  735. output: model?.cost?.output ?? existingModel?.cost?.output ?? 0,
  736. cache: {
  737. read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0,
  738. write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0,
  739. },
  740. },
  741. options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}),
  742. limit: {
  743. context: model.limit?.context ?? existingModel?.limit?.context ?? 0,
  744. output: model.limit?.output ?? existingModel?.limit?.output ?? 0,
  745. },
  746. headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
  747. family: model.family ?? existingModel?.family ?? "",
  748. release_date: model.release_date ?? existingModel?.release_date ?? "",
  749. variants: {},
  750. }
  751. const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
  752. parsedModel.variants = mapValues(
  753. pickBy(merged, (v) => !v.disabled),
  754. (v) => omit(v, ["disabled"]),
  755. )
  756. parsed.models[modelID] = parsedModel
  757. }
  758. database[providerID] = parsed
  759. }
  760. // load env
  761. const env = Env.all()
  762. for (const [providerID, provider] of Object.entries(database)) {
  763. if (disabled.has(providerID)) continue
  764. const apiKey = provider.env.map((item) => env[item]).find(Boolean)
  765. if (!apiKey) continue
  766. mergeProvider(providerID, {
  767. source: "env",
  768. key: provider.env.length === 1 ? apiKey : undefined,
  769. })
  770. }
  771. // load apikeys
  772. for (const [providerID, provider] of Object.entries(await Auth.all())) {
  773. if (disabled.has(providerID)) continue
  774. if (provider.type === "api") {
  775. mergeProvider(providerID, {
  776. source: "api",
  777. key: provider.key,
  778. })
  779. }
  780. }
  781. for (const plugin of await Plugin.list()) {
  782. if (!plugin.auth) continue
  783. const providerID = plugin.auth.provider
  784. if (disabled.has(providerID)) continue
  785. // For github-copilot plugin, check if auth exists for either github-copilot or github-copilot-enterprise
  786. let hasAuth = false
  787. const auth = await Auth.get(providerID)
  788. if (auth) hasAuth = true
  789. // Special handling for github-copilot: also check for enterprise auth
  790. if (providerID === "github-copilot" && !hasAuth) {
  791. const enterpriseAuth = await Auth.get("github-copilot-enterprise")
  792. if (enterpriseAuth) hasAuth = true
  793. }
  794. if (!hasAuth) continue
  795. if (!plugin.auth.loader) continue
  796. // Load for the main provider if auth exists
  797. if (auth) {
  798. const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider])
  799. const opts = options ?? {}
  800. const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
  801. mergeProvider(providerID, patch)
  802. }
  803. // If this is github-copilot plugin, also register for github-copilot-enterprise if auth exists
  804. if (providerID === "github-copilot") {
  805. const enterpriseProviderID = "github-copilot-enterprise"
  806. if (!disabled.has(enterpriseProviderID)) {
  807. const enterpriseAuth = await Auth.get(enterpriseProviderID)
  808. if (enterpriseAuth) {
  809. const enterpriseOptions = await plugin.auth.loader(
  810. () => Auth.get(enterpriseProviderID) as any,
  811. database[enterpriseProviderID],
  812. )
  813. const opts = enterpriseOptions ?? {}
  814. const patch: Partial<Info> = providers[enterpriseProviderID]
  815. ? { options: opts }
  816. : { source: "custom", options: opts }
  817. mergeProvider(enterpriseProviderID, patch)
  818. }
  819. }
  820. }
  821. }
  822. for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
  823. if (disabled.has(providerID)) continue
  824. const data = database[providerID]
  825. if (!data) {
  826. log.error("Provider does not exist in model list " + providerID)
  827. continue
  828. }
  829. const result = await fn(data)
  830. if (result && (result.autoload || providers[providerID])) {
  831. if (result.getModel) modelLoaders[providerID] = result.getModel
  832. const opts = result.options ?? {}
  833. const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
  834. mergeProvider(providerID, patch)
  835. }
  836. }
  837. // load config
  838. for (const [providerID, provider] of configProviders) {
  839. const partial: Partial<Info> = { source: "config" }
  840. if (provider.env) partial.env = provider.env
  841. if (provider.name) partial.name = provider.name
  842. if (provider.options) partial.options = provider.options
  843. mergeProvider(providerID, partial)
  844. }
  845. for (const [providerID, provider] of Object.entries(providers)) {
  846. if (!isProviderAllowed(providerID)) {
  847. delete providers[providerID]
  848. continue
  849. }
  850. const configProvider = config.provider?.[providerID]
  851. for (const [modelID, model] of Object.entries(provider.models)) {
  852. model.api.id = model.api.id ?? model.id ?? modelID
  853. if (modelID === "gpt-5-chat-latest" || (providerID === "openrouter" && modelID === "openai/gpt-5-chat"))
  854. delete provider.models[modelID]
  855. if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) delete provider.models[modelID]
  856. if (model.status === "deprecated") delete provider.models[modelID]
  857. if (
  858. (configProvider?.blacklist && configProvider.blacklist.includes(modelID)) ||
  859. (configProvider?.whitelist && !configProvider.whitelist.includes(modelID))
  860. )
  861. delete provider.models[modelID]
  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({ 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. }