| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219 |
- import z from "zod"
- import fuzzysort from "fuzzysort"
- import { Config } from "../config/config"
- import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
- import { NoSuchModelError, type Provider as SDK } from "ai"
- import { Log } from "../util/log"
- import { BunProc } from "../bun"
- import { Plugin } from "../plugin"
- import { ModelsDev } from "./models"
- import { NamedError } from "@opencode-ai/util/error"
- import { Auth } from "../auth"
- import { Env } from "../env"
- import { Instance } from "../project/instance"
- import { Flag } from "../flag/flag"
- import { iife } from "@/util/iife"
- // Direct imports for bundled providers
- import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
- import { createAnthropic } from "@ai-sdk/anthropic"
- import { createAzure } from "@ai-sdk/azure"
- import { createGoogleGenerativeAI } from "@ai-sdk/google"
- import { createVertex } from "@ai-sdk/google-vertex"
- import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic"
- import { createOpenAI } from "@ai-sdk/openai"
- import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
- import { createOpenRouter, type LanguageModelV2 } from "@openrouter/ai-sdk-provider"
- import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/openai-compatible/src"
- import { createXai } from "@ai-sdk/xai"
- import { createMistral } from "@ai-sdk/mistral"
- import { createGroq } from "@ai-sdk/groq"
- import { createDeepInfra } from "@ai-sdk/deepinfra"
- import { createCerebras } from "@ai-sdk/cerebras"
- import { createCohere } from "@ai-sdk/cohere"
- import { createGateway } from "@ai-sdk/gateway"
- import { createTogetherAI } from "@ai-sdk/togetherai"
- import { createPerplexity } from "@ai-sdk/perplexity"
- import { createVercel } from "@ai-sdk/vercel"
- import { createGitLab } from "@gitlab/gitlab-ai-provider"
- import { ProviderTransform } from "./transform"
- export namespace Provider {
- const log = Log.create({ service: "provider" })
- function isGpt5OrLater(modelID: string): boolean {
- const match = /^gpt-(\d+)/.exec(modelID)
- if (!match) {
- return false
- }
- return Number(match[1]) >= 5
- }
- function shouldUseCopilotResponsesApi(modelID: string): boolean {
- return isGpt5OrLater(modelID) && !modelID.startsWith("gpt-5-mini")
- }
- const BUNDLED_PROVIDERS: Record<string, (options: any) => SDK> = {
- "@ai-sdk/amazon-bedrock": createAmazonBedrock,
- "@ai-sdk/anthropic": createAnthropic,
- "@ai-sdk/azure": createAzure,
- "@ai-sdk/google": createGoogleGenerativeAI,
- "@ai-sdk/google-vertex": createVertex,
- "@ai-sdk/google-vertex/anthropic": createVertexAnthropic,
- "@ai-sdk/openai": createOpenAI,
- "@ai-sdk/openai-compatible": createOpenAICompatible,
- "@openrouter/ai-sdk-provider": createOpenRouter,
- "@ai-sdk/xai": createXai,
- "@ai-sdk/mistral": createMistral,
- "@ai-sdk/groq": createGroq,
- "@ai-sdk/deepinfra": createDeepInfra,
- "@ai-sdk/cerebras": createCerebras,
- "@ai-sdk/cohere": createCohere,
- "@ai-sdk/gateway": createGateway,
- "@ai-sdk/togetherai": createTogetherAI,
- "@ai-sdk/perplexity": createPerplexity,
- "@ai-sdk/vercel": createVercel,
- "@gitlab/gitlab-ai-provider": createGitLab,
- // @ts-ignore (TODO: kill this code so we dont have to maintain it)
- "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
- }
- type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
- type CustomLoader = (provider: Info) => Promise<{
- autoload: boolean
- getModel?: CustomModelLoader
- options?: Record<string, any>
- }>
- const CUSTOM_LOADERS: Record<string, CustomLoader> = {
- async anthropic() {
- return {
- autoload: false,
- options: {
- headers: {
- "anthropic-beta":
- "claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
- },
- },
- }
- },
- async opencode(input) {
- const hasKey = await (async () => {
- const env = Env.all()
- if (input.env.some((item) => env[item])) return true
- if (await Auth.get(input.id)) return true
- const config = await Config.get()
- if (config.provider?.["opencode"]?.options?.apiKey) return true
- return false
- })()
- if (!hasKey) {
- for (const [key, value] of Object.entries(input.models)) {
- if (value.cost.input === 0) continue
- delete input.models[key]
- }
- }
- return {
- autoload: Object.keys(input.models).length > 0,
- options: hasKey ? {} : { apiKey: "public" },
- }
- },
- openai: async () => {
- return {
- autoload: false,
- async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
- return sdk.responses(modelID)
- },
- options: {},
- }
- },
- "github-copilot": async () => {
- return {
- autoload: false,
- async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
- if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID)
- return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
- },
- options: {},
- }
- },
- "github-copilot-enterprise": async () => {
- return {
- autoload: false,
- async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
- if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID)
- return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
- },
- options: {},
- }
- },
- azure: async () => {
- return {
- autoload: false,
- async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
- if (options?.["useCompletionUrls"]) {
- return sdk.chat(modelID)
- } else {
- return sdk.responses(modelID)
- }
- },
- options: {},
- }
- },
- "azure-cognitive-services": async () => {
- const resourceName = Env.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
- return {
- autoload: false,
- async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
- if (options?.["useCompletionUrls"]) {
- return sdk.chat(modelID)
- } else {
- return sdk.responses(modelID)
- }
- },
- options: {
- baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
- },
- }
- },
- "amazon-bedrock": async () => {
- const config = await Config.get()
- const providerConfig = config.provider?.["amazon-bedrock"]
- const auth = await Auth.get("amazon-bedrock")
- // Region precedence: 1) config file, 2) env var, 3) default
- const configRegion = providerConfig?.options?.region
- const envRegion = Env.get("AWS_REGION")
- const defaultRegion = configRegion ?? envRegion ?? "us-east-1"
- // Profile: config file takes precedence over env var
- const configProfile = providerConfig?.options?.profile
- const envProfile = Env.get("AWS_PROFILE")
- const profile = configProfile ?? envProfile
- const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID")
- const awsBearerToken = iife(() => {
- const envToken = Env.get("AWS_BEARER_TOKEN_BEDROCK")
- if (envToken) return envToken
- if (auth?.type === "api") {
- Env.set("AWS_BEARER_TOKEN_BEDROCK", auth.key)
- return auth.key
- }
- return undefined
- })
- const awsWebIdentityTokenFile = Env.get("AWS_WEB_IDENTITY_TOKEN_FILE")
- if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile) return { autoload: false }
- const providerOptions: AmazonBedrockProviderSettings = {
- region: defaultRegion,
- }
- // Only use credential chain if no bearer token exists
- // Bearer token takes precedence over credential chain (profiles, access keys, IAM roles, web identity tokens)
- if (!awsBearerToken) {
- const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
- // Build credential provider options (only pass profile if specified)
- const credentialProviderOptions = profile ? { profile } : {}
- providerOptions.credentialProvider = fromNodeProviderChain(credentialProviderOptions)
- }
- // Add custom endpoint if specified (endpoint takes precedence over baseURL)
- const endpoint = providerConfig?.options?.endpoint ?? providerConfig?.options?.baseURL
- if (endpoint) {
- providerOptions.baseURL = endpoint
- }
- return {
- autoload: true,
- options: providerOptions,
- async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
- // Skip region prefixing if model already has a cross-region inference profile prefix
- if (modelID.startsWith("global.") || modelID.startsWith("jp.")) {
- return sdk.languageModel(modelID)
- }
- // Region resolution precedence (highest to lowest):
- // 1. options.region from opencode.json provider config
- // 2. defaultRegion from AWS_REGION environment variable
- // 3. Default "us-east-1" (baked into defaultRegion)
- const region = options?.region ?? defaultRegion
- let regionPrefix = region.split("-")[0]
- switch (regionPrefix) {
- case "us": {
- const modelRequiresPrefix = [
- "nova-micro",
- "nova-lite",
- "nova-pro",
- "nova-premier",
- "nova-2",
- "claude",
- "deepseek",
- ].some((m) => modelID.includes(m))
- const isGovCloud = region.startsWith("us-gov")
- if (modelRequiresPrefix && !isGovCloud) {
- modelID = `${regionPrefix}.${modelID}`
- }
- break
- }
- case "eu": {
- const regionRequiresPrefix = [
- "eu-west-1",
- "eu-west-2",
- "eu-west-3",
- "eu-north-1",
- "eu-central-1",
- "eu-south-1",
- "eu-south-2",
- ].some((r) => region.includes(r))
- const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((m) =>
- modelID.includes(m),
- )
- if (regionRequiresPrefix && modelRequiresPrefix) {
- modelID = `${regionPrefix}.${modelID}`
- }
- break
- }
- case "ap": {
- const isAustraliaRegion = ["ap-southeast-2", "ap-southeast-4"].includes(region)
- const isTokyoRegion = region === "ap-northeast-1"
- if (
- isAustraliaRegion &&
- ["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((m) => modelID.includes(m))
- ) {
- regionPrefix = "au"
- modelID = `${regionPrefix}.${modelID}`
- } else if (isTokyoRegion) {
- // Tokyo region uses jp. prefix for cross-region inference
- const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) =>
- modelID.includes(m),
- )
- if (modelRequiresPrefix) {
- regionPrefix = "jp"
- modelID = `${regionPrefix}.${modelID}`
- }
- } else {
- // Other APAC regions use apac. prefix
- const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) =>
- modelID.includes(m),
- )
- if (modelRequiresPrefix) {
- regionPrefix = "apac"
- modelID = `${regionPrefix}.${modelID}`
- }
- }
- break
- }
- }
- return sdk.languageModel(modelID)
- },
- }
- },
- openrouter: async () => {
- return {
- autoload: false,
- options: {
- headers: {
- "HTTP-Referer": "https://opencode.ai/",
- "X-Title": "opencode",
- },
- },
- }
- },
- vercel: async () => {
- return {
- autoload: false,
- options: {
- headers: {
- "http-referer": "https://opencode.ai/",
- "x-title": "opencode",
- },
- },
- }
- },
- "google-vertex": async () => {
- const project = Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
- const location = Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-east5"
- const autoload = Boolean(project)
- if (!autoload) return { autoload: false }
- return {
- autoload: true,
- options: {
- project,
- location,
- },
- async getModel(sdk: any, modelID: string) {
- const id = String(modelID).trim()
- return sdk.languageModel(id)
- },
- }
- },
- "google-vertex-anthropic": async () => {
- const project = Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
- const location = Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "global"
- const autoload = Boolean(project)
- if (!autoload) return { autoload: false }
- return {
- autoload: true,
- options: {
- project,
- location,
- },
- async getModel(sdk: any, modelID) {
- const id = String(modelID).trim()
- return sdk.languageModel(id)
- },
- }
- },
- "sap-ai-core": async () => {
- const auth = await Auth.get("sap-ai-core")
- const envServiceKey = iife(() => {
- const envAICoreServiceKey = Env.get("AICORE_SERVICE_KEY")
- if (envAICoreServiceKey) return envAICoreServiceKey
- if (auth?.type === "api") {
- Env.set("AICORE_SERVICE_KEY", auth.key)
- return auth.key
- }
- return undefined
- })
- const deploymentId = Env.get("AICORE_DEPLOYMENT_ID")
- const resourceGroup = Env.get("AICORE_RESOURCE_GROUP")
- return {
- autoload: !!envServiceKey,
- options: envServiceKey ? { deploymentId, resourceGroup } : {},
- async getModel(sdk: any, modelID: string) {
- return sdk(modelID)
- },
- }
- },
- zenmux: async () => {
- return {
- autoload: false,
- options: {
- headers: {
- "HTTP-Referer": "https://opencode.ai/",
- "X-Title": "opencode",
- },
- },
- }
- },
- gitlab: async (input) => {
- const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com"
- const auth = await Auth.get(input.id)
- const apiKey = await (async () => {
- if (auth?.type === "oauth") return auth.access
- if (auth?.type === "api") return auth.key
- return Env.get("GITLAB_TOKEN")
- })()
- const config = await Config.get()
- const providerConfig = config.provider?.["gitlab"]
- return {
- autoload: !!apiKey,
- options: {
- instanceUrl,
- apiKey,
- featureFlags: {
- duo_agent_platform_agentic_chat: true,
- duo_agent_platform: true,
- ...(providerConfig?.options?.featureFlags || {}),
- },
- },
- async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string) {
- return sdk.agenticChat(modelID, {
- featureFlags: {
- duo_agent_platform_agentic_chat: true,
- duo_agent_platform: true,
- ...(providerConfig?.options?.featureFlags || {}),
- },
- })
- },
- }
- },
- "cloudflare-ai-gateway": async (input) => {
- const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
- const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")
- if (!accountId || !gateway) return { autoload: false }
- // Get API token from env or auth prompt
- const apiToken = await (async () => {
- const envToken = Env.get("CLOUDFLARE_API_TOKEN")
- if (envToken) return envToken
- const auth = await Auth.get(input.id)
- if (auth?.type === "api") return auth.key
- return undefined
- })()
- return {
- autoload: true,
- async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
- return sdk.languageModel(modelID)
- },
- options: {
- baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gateway}/compat`,
- headers: {
- // Cloudflare AI Gateway uses cf-aig-authorization for authenticated gateways
- // This enables Unified Billing where Cloudflare handles upstream provider auth
- ...(apiToken ? { "cf-aig-authorization": `Bearer ${apiToken}` } : {}),
- "HTTP-Referer": "https://opencode.ai/",
- "X-Title": "opencode",
- },
- // Custom fetch to handle parameter transformation and auth
- fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
- const headers = new Headers(init?.headers)
- // Strip Authorization header - AI Gateway uses cf-aig-authorization instead
- headers.delete("Authorization")
- // Transform max_tokens to max_completion_tokens for newer models
- if (init?.body && init.method === "POST") {
- try {
- const body = JSON.parse(init.body as string)
- if (body.max_tokens !== undefined && !body.max_completion_tokens) {
- body.max_completion_tokens = body.max_tokens
- delete body.max_tokens
- init = { ...init, body: JSON.stringify(body) }
- }
- } catch (e) {
- // If body parsing fails, continue with original request
- }
- }
- return fetch(input, { ...init, headers })
- },
- },
- }
- },
- cerebras: async () => {
- return {
- autoload: false,
- options: {
- headers: {
- "X-Cerebras-3rd-Party-Integration": "opencode",
- },
- },
- }
- },
- }
- export const Model = z
- .object({
- id: z.string(),
- providerID: z.string(),
- api: z.object({
- id: z.string(),
- url: z.string(),
- npm: z.string(),
- }),
- name: z.string(),
- family: z.string().optional(),
- capabilities: z.object({
- temperature: z.boolean(),
- reasoning: z.boolean(),
- attachment: z.boolean(),
- toolcall: z.boolean(),
- input: z.object({
- text: z.boolean(),
- audio: z.boolean(),
- image: z.boolean(),
- video: z.boolean(),
- pdf: z.boolean(),
- }),
- output: z.object({
- text: z.boolean(),
- audio: z.boolean(),
- image: z.boolean(),
- video: z.boolean(),
- pdf: z.boolean(),
- }),
- interleaved: z.union([
- z.boolean(),
- z.object({
- field: z.enum(["reasoning_content", "reasoning_details"]),
- }),
- ]),
- }),
- cost: z.object({
- input: z.number(),
- output: z.number(),
- cache: z.object({
- read: z.number(),
- write: z.number(),
- }),
- experimentalOver200K: z
- .object({
- input: z.number(),
- output: z.number(),
- cache: z.object({
- read: z.number(),
- write: z.number(),
- }),
- })
- .optional(),
- }),
- limit: z.object({
- context: z.number(),
- input: z.number().optional(),
- output: z.number(),
- }),
- status: z.enum(["alpha", "beta", "deprecated", "active"]),
- options: z.record(z.string(), z.any()),
- headers: z.record(z.string(), z.string()),
- release_date: z.string(),
- variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
- })
- .meta({
- ref: "Model",
- })
- export type Model = z.infer<typeof Model>
- export const Info = z
- .object({
- id: z.string(),
- name: z.string(),
- source: z.enum(["env", "config", "custom", "api"]),
- env: z.string().array(),
- key: z.string().optional(),
- options: z.record(z.string(), z.any()),
- models: z.record(z.string(), Model),
- })
- .meta({
- ref: "Provider",
- })
- export type Info = z.infer<typeof Info>
- function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
- const m: Model = {
- id: model.id,
- providerID: provider.id,
- name: model.name,
- family: model.family,
- api: {
- id: model.id,
- url: provider.api!,
- npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
- },
- status: model.status ?? "active",
- headers: model.headers ?? {},
- options: model.options ?? {},
- cost: {
- input: model.cost?.input ?? 0,
- output: model.cost?.output ?? 0,
- cache: {
- read: model.cost?.cache_read ?? 0,
- write: model.cost?.cache_write ?? 0,
- },
- experimentalOver200K: model.cost?.context_over_200k
- ? {
- cache: {
- read: model.cost.context_over_200k.cache_read ?? 0,
- write: model.cost.context_over_200k.cache_write ?? 0,
- },
- input: model.cost.context_over_200k.input,
- output: model.cost.context_over_200k.output,
- }
- : undefined,
- },
- limit: {
- context: model.limit.context,
- input: model.limit.input,
- output: model.limit.output,
- },
- capabilities: {
- temperature: model.temperature,
- reasoning: model.reasoning,
- attachment: model.attachment,
- toolcall: model.tool_call,
- input: {
- text: model.modalities?.input?.includes("text") ?? false,
- audio: model.modalities?.input?.includes("audio") ?? false,
- image: model.modalities?.input?.includes("image") ?? false,
- video: model.modalities?.input?.includes("video") ?? false,
- pdf: model.modalities?.input?.includes("pdf") ?? false,
- },
- output: {
- text: model.modalities?.output?.includes("text") ?? false,
- audio: model.modalities?.output?.includes("audio") ?? false,
- image: model.modalities?.output?.includes("image") ?? false,
- video: model.modalities?.output?.includes("video") ?? false,
- pdf: model.modalities?.output?.includes("pdf") ?? false,
- },
- interleaved: model.interleaved ?? false,
- },
- release_date: model.release_date,
- variants: {},
- }
- m.variants = mapValues(ProviderTransform.variants(m), (v) => v)
- return m
- }
- export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
- return {
- id: provider.id,
- source: "custom",
- name: provider.name,
- env: provider.env ?? [],
- options: {},
- models: mapValues(provider.models, (model) => fromModelsDevModel(provider, model)),
- }
- }
- const state = Instance.state(async () => {
- using _ = log.time("state")
- const config = await Config.get()
- const modelsDev = await ModelsDev.get()
- const database = mapValues(modelsDev, fromModelsDevProvider)
- const disabled = new Set(config.disabled_providers ?? [])
- const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null
- function isProviderAllowed(providerID: string): boolean {
- if (enabled && !enabled.has(providerID)) return false
- if (disabled.has(providerID)) return false
- return true
- }
- const providers: { [providerID: string]: Info } = {}
- const languages = new Map<string, LanguageModelV2>()
- const modelLoaders: {
- [providerID: string]: CustomModelLoader
- } = {}
- const sdk = new Map<number, SDK>()
- log.info("init")
- const configProviders = Object.entries(config.provider ?? {})
- // Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot
- if (database["github-copilot"]) {
- const githubCopilot = database["github-copilot"]
- database["github-copilot-enterprise"] = {
- ...githubCopilot,
- id: "github-copilot-enterprise",
- name: "GitHub Copilot Enterprise",
- models: mapValues(githubCopilot.models, (model) => ({
- ...model,
- providerID: "github-copilot-enterprise",
- })),
- }
- }
- function mergeProvider(providerID: string, provider: Partial<Info>) {
- const existing = providers[providerID]
- if (existing) {
- // @ts-expect-error
- providers[providerID] = mergeDeep(existing, provider)
- return
- }
- const match = database[providerID]
- if (!match) return
- // @ts-expect-error
- providers[providerID] = mergeDeep(match, provider)
- }
- // extend database from config
- for (const [providerID, provider] of configProviders) {
- const existing = database[providerID]
- const parsed: Info = {
- id: providerID,
- name: provider.name ?? existing?.name ?? providerID,
- env: provider.env ?? existing?.env ?? [],
- options: mergeDeep(existing?.options ?? {}, provider.options ?? {}),
- source: "config",
- models: existing?.models ?? {},
- }
- for (const [modelID, model] of Object.entries(provider.models ?? {})) {
- const existingModel = parsed.models[model.id ?? modelID]
- const name = iife(() => {
- if (model.name) return model.name
- if (model.id && model.id !== modelID) return modelID
- return existingModel?.name ?? modelID
- })
- const parsedModel: Model = {
- id: modelID,
- api: {
- id: model.id ?? existingModel?.api.id ?? modelID,
- npm:
- model.provider?.npm ??
- provider.npm ??
- existingModel?.api.npm ??
- modelsDev[providerID]?.npm ??
- "@ai-sdk/openai-compatible",
- url: provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api,
- },
- status: model.status ?? existingModel?.status ?? "active",
- name,
- providerID,
- capabilities: {
- temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false,
- reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false,
- attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false,
- toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true,
- input: {
- text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true,
- audio: model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false,
- image: model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false,
- video: model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false,
- pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false,
- },
- output: {
- text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true,
- audio: model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? false,
- image: model.modalities?.output?.includes("image") ?? existingModel?.capabilities.output.image ?? false,
- video: model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false,
- pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
- },
- interleaved: model.interleaved ?? false,
- },
- cost: {
- input: model?.cost?.input ?? existingModel?.cost?.input ?? 0,
- output: model?.cost?.output ?? existingModel?.cost?.output ?? 0,
- cache: {
- read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0,
- write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0,
- },
- },
- options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}),
- limit: {
- context: model.limit?.context ?? existingModel?.limit?.context ?? 0,
- output: model.limit?.output ?? existingModel?.limit?.output ?? 0,
- },
- headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
- family: model.family ?? existingModel?.family ?? "",
- release_date: model.release_date ?? existingModel?.release_date ?? "",
- variants: {},
- }
- const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
- parsedModel.variants = mapValues(
- pickBy(merged, (v) => !v.disabled),
- (v) => omit(v, ["disabled"]),
- )
- parsed.models[modelID] = parsedModel
- }
- database[providerID] = parsed
- }
- // load env
- const env = Env.all()
- for (const [providerID, provider] of Object.entries(database)) {
- if (disabled.has(providerID)) continue
- const apiKey = provider.env.map((item) => env[item]).find(Boolean)
- if (!apiKey) continue
- mergeProvider(providerID, {
- source: "env",
- key: provider.env.length === 1 ? apiKey : undefined,
- })
- }
- // load apikeys
- for (const [providerID, provider] of Object.entries(await Auth.all())) {
- if (disabled.has(providerID)) continue
- if (provider.type === "api") {
- mergeProvider(providerID, {
- source: "api",
- key: provider.key,
- })
- }
- }
- for (const plugin of await Plugin.list()) {
- if (!plugin.auth) continue
- const providerID = plugin.auth.provider
- if (disabled.has(providerID)) continue
- // For github-copilot plugin, check if auth exists for either github-copilot or github-copilot-enterprise
- let hasAuth = false
- const auth = await Auth.get(providerID)
- if (auth) hasAuth = true
- // Special handling for github-copilot: also check for enterprise auth
- if (providerID === "github-copilot" && !hasAuth) {
- const enterpriseAuth = await Auth.get("github-copilot-enterprise")
- if (enterpriseAuth) hasAuth = true
- }
- if (!hasAuth) continue
- if (!plugin.auth.loader) continue
- // Load for the main provider if auth exists
- if (auth) {
- const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider])
- const opts = options ?? {}
- const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
- mergeProvider(providerID, patch)
- }
- // If this is github-copilot plugin, also register for github-copilot-enterprise if auth exists
- if (providerID === "github-copilot") {
- const enterpriseProviderID = "github-copilot-enterprise"
- if (!disabled.has(enterpriseProviderID)) {
- const enterpriseAuth = await Auth.get(enterpriseProviderID)
- if (enterpriseAuth) {
- const enterpriseOptions = await plugin.auth.loader(
- () => Auth.get(enterpriseProviderID) as any,
- database[enterpriseProviderID],
- )
- const opts = enterpriseOptions ?? {}
- const patch: Partial<Info> = providers[enterpriseProviderID]
- ? { options: opts }
- : { source: "custom", options: opts }
- mergeProvider(enterpriseProviderID, patch)
- }
- }
- }
- }
- for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
- if (disabled.has(providerID)) continue
- const data = database[providerID]
- if (!data) {
- log.error("Provider does not exist in model list " + providerID)
- continue
- }
- const result = await fn(data)
- if (result && (result.autoload || providers[providerID])) {
- if (result.getModel) modelLoaders[providerID] = result.getModel
- const opts = result.options ?? {}
- const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
- mergeProvider(providerID, patch)
- }
- }
- // load config
- for (const [providerID, provider] of configProviders) {
- const partial: Partial<Info> = { source: "config" }
- if (provider.env) partial.env = provider.env
- if (provider.name) partial.name = provider.name
- if (provider.options) partial.options = provider.options
- mergeProvider(providerID, partial)
- }
- for (const [providerID, provider] of Object.entries(providers)) {
- if (!isProviderAllowed(providerID)) {
- delete providers[providerID]
- continue
- }
- const configProvider = config.provider?.[providerID]
- for (const [modelID, model] of Object.entries(provider.models)) {
- model.api.id = model.api.id ?? model.id ?? modelID
- if (modelID === "gpt-5-chat-latest" || (providerID === "openrouter" && modelID === "openai/gpt-5-chat"))
- delete provider.models[modelID]
- if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) delete provider.models[modelID]
- if (model.status === "deprecated") delete provider.models[modelID]
- if (
- (configProvider?.blacklist && configProvider.blacklist.includes(modelID)) ||
- (configProvider?.whitelist && !configProvider.whitelist.includes(modelID))
- )
- delete provider.models[modelID]
- model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
- // Filter out disabled variants from config
- const configVariants = configProvider?.models?.[modelID]?.variants
- if (configVariants && model.variants) {
- const merged = mergeDeep(model.variants, configVariants)
- model.variants = mapValues(
- pickBy(merged, (v) => !v.disabled),
- (v) => omit(v, ["disabled"]),
- )
- }
- }
- if (Object.keys(provider.models).length === 0) {
- delete providers[providerID]
- continue
- }
- log.info("found", { providerID })
- }
- return {
- models: languages,
- providers,
- sdk,
- modelLoaders,
- }
- })
- export async function list() {
- return state().then((state) => state.providers)
- }
- async function getSDK(model: Model) {
- try {
- using _ = log.time("getSDK", {
- providerID: model.providerID,
- })
- const s = await state()
- const provider = s.providers[model.providerID]
- const options = { ...provider.options }
- if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) {
- options["includeUsage"] = true
- }
- if (!options["baseURL"]) options["baseURL"] = model.api.url
- if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key
- if (model.headers)
- options["headers"] = {
- ...options["headers"],
- ...model.headers,
- }
- const key = Bun.hash.xxHash32(JSON.stringify({ providerID: model.providerID, npm: model.api.npm, options }))
- const existing = s.sdk.get(key)
- if (existing) return existing
- const customFetch = options["fetch"]
- options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
- // Preserve custom fetch if it exists, wrap it with timeout logic
- const fetchFn = customFetch ?? fetch
- const opts = init ?? {}
- if (options["timeout"] !== undefined && options["timeout"] !== null) {
- const signals: AbortSignal[] = []
- if (opts.signal) signals.push(opts.signal)
- if (options["timeout"] !== false) signals.push(AbortSignal.timeout(options["timeout"]))
- const combined = signals.length > 1 ? AbortSignal.any(signals) : signals[0]
- opts.signal = combined
- }
- // Strip openai itemId metadata following what codex does
- // Codex uses #[serde(skip_serializing)] on id fields for all item types:
- // Message, Reasoning, FunctionCall, LocalShellCall, CustomToolCall, WebSearchCall
- // IDs are only re-attached for Azure with store=true
- if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") {
- const body = JSON.parse(opts.body as string)
- const isAzure = model.providerID.includes("azure")
- const keepIds = isAzure && body.store === true
- if (!keepIds && Array.isArray(body.input)) {
- for (const item of body.input) {
- if ("id" in item) {
- delete item.id
- }
- }
- opts.body = JSON.stringify(body)
- }
- }
- return fetchFn(input, {
- ...opts,
- // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
- timeout: false,
- })
- }
- // Special case: google-vertex-anthropic uses a subpath import
- const bundledKey =
- model.providerID === "google-vertex-anthropic" ? "@ai-sdk/google-vertex/anthropic" : model.api.npm
- const bundledFn = BUNDLED_PROVIDERS[bundledKey]
- if (bundledFn) {
- log.info("using bundled provider", { providerID: model.providerID, pkg: bundledKey })
- const loaded = bundledFn({
- name: model.providerID,
- ...options,
- })
- s.sdk.set(key, loaded)
- return loaded as SDK
- }
- let installedPath: string
- if (!model.api.npm.startsWith("file://")) {
- installedPath = await BunProc.install(model.api.npm, "latest")
- } else {
- log.info("loading local provider", { pkg: model.api.npm })
- installedPath = model.api.npm
- }
- const mod = await import(installedPath)
- const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
- const loaded = fn({
- name: model.providerID,
- ...options,
- })
- s.sdk.set(key, loaded)
- return loaded as SDK
- } catch (e) {
- throw new InitError({ providerID: model.providerID }, { cause: e })
- }
- }
- export async function getProvider(providerID: string) {
- return state().then((s) => s.providers[providerID])
- }
- export async function getModel(providerID: string, modelID: string) {
- const s = await state()
- const provider = s.providers[providerID]
- if (!provider) {
- const availableProviders = Object.keys(s.providers)
- const matches = fuzzysort.go(providerID, availableProviders, { limit: 3, threshold: -10000 })
- const suggestions = matches.map((m) => m.target)
- throw new ModelNotFoundError({ providerID, modelID, suggestions })
- }
- const info = provider.models[modelID]
- if (!info) {
- const availableModels = Object.keys(provider.models)
- const matches = fuzzysort.go(modelID, availableModels, { limit: 3, threshold: -10000 })
- const suggestions = matches.map((m) => m.target)
- throw new ModelNotFoundError({ providerID, modelID, suggestions })
- }
- return info
- }
- export async function getLanguage(model: Model): Promise<LanguageModelV2> {
- const s = await state()
- const key = `${model.providerID}/${model.id}`
- if (s.models.has(key)) return s.models.get(key)!
- const provider = s.providers[model.providerID]
- const sdk = await getSDK(model)
- try {
- const language = s.modelLoaders[model.providerID]
- ? await s.modelLoaders[model.providerID](sdk, model.api.id, provider.options)
- : sdk.languageModel(model.api.id)
- s.models.set(key, language)
- return language
- } catch (e) {
- if (e instanceof NoSuchModelError)
- throw new ModelNotFoundError(
- {
- modelID: model.id,
- providerID: model.providerID,
- },
- { cause: e },
- )
- throw e
- }
- }
- export async function closest(providerID: string, query: string[]) {
- const s = await state()
- const provider = s.providers[providerID]
- if (!provider) return undefined
- for (const item of query) {
- for (const modelID of Object.keys(provider.models)) {
- if (modelID.includes(item))
- return {
- providerID,
- modelID,
- }
- }
- }
- }
- export async function getSmallModel(providerID: string) {
- const cfg = await Config.get()
- if (cfg.small_model) {
- const parsed = parseModel(cfg.small_model)
- return getModel(parsed.providerID, parsed.modelID)
- }
- const provider = await state().then((state) => state.providers[providerID])
- if (provider) {
- let priority = [
- "claude-haiku-4-5",
- "claude-haiku-4.5",
- "3-5-haiku",
- "3.5-haiku",
- "gemini-3-flash",
- "gemini-2.5-flash",
- "gpt-5-nano",
- ]
- if (providerID.startsWith("opencode")) {
- priority = ["gpt-5-nano"]
- }
- if (providerID.startsWith("github-copilot")) {
- // prioritize free models for github copilot
- priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority]
- }
- for (const item of priority) {
- for (const model of Object.keys(provider.models)) {
- if (model.includes(item)) return getModel(providerID, model)
- }
- }
- }
- // Check if opencode provider is available before using it
- const opencodeProvider = await state().then((state) => state.providers["opencode"])
- if (opencodeProvider && opencodeProvider.models["gpt-5-nano"]) {
- return getModel("opencode", "gpt-5-nano")
- }
- return undefined
- }
- const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
- export function sort(models: Model[]) {
- return sortBy(
- models,
- [(model) => priority.findIndex((filter) => model.id.includes(filter)), "desc"],
- [(model) => (model.id.includes("latest") ? 0 : 1), "asc"],
- [(model) => model.id, "desc"],
- )
- }
- export async function defaultModel() {
- const cfg = await Config.get()
- if (cfg.model) return parseModel(cfg.model)
- const provider = await list()
- .then((val) => Object.values(val))
- .then((x) => x.find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id)))
- if (!provider) throw new Error("no providers found")
- const [model] = sort(Object.values(provider.models))
- if (!model) throw new Error("no models found")
- return {
- providerID: provider.id,
- modelID: model.id,
- }
- }
- export function parseModel(model: string) {
- const [providerID, ...rest] = model.split("/")
- return {
- providerID: providerID,
- modelID: rest.join("/"),
- }
- }
- export const ModelNotFoundError = NamedError.create(
- "ProviderModelNotFoundError",
- z.object({
- providerID: z.string(),
- modelID: z.string(),
- suggestions: z.array(z.string()).optional(),
- }),
- )
- export const InitError = NamedError.create(
- "ProviderInitError",
- z.object({
- providerID: z.string(),
- }),
- )
- }
|