| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729 |
- import { test, expect } from "bun:test"
- import path from "path"
- import { tmpdir } from "../fixture/fixture"
- import { Instance } from "../../src/project/instance"
- import { Provider } from "../../src/provider/provider"
- import { Env } from "../../src/env"
- test("provider loaded from env variable", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["anthropic"]).toBeDefined()
- // Note: source becomes "custom" because CUSTOM_LOADERS run after env loading
- // and anthropic has a custom loader that merges additional options
- expect(providers["anthropic"].source).toBe("custom")
- },
- })
- })
- test("provider loaded from config with apiKey option", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- anthropic: {
- options: {
- apiKey: "config-api-key",
- },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["anthropic"]).toBeDefined()
- },
- })
- })
- test("disabled_providers excludes provider", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- disabled_providers: ["anthropic"],
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["anthropic"]).toBeUndefined()
- },
- })
- })
- test("enabled_providers restricts to only listed providers", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- enabled_providers: ["anthropic"],
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- Env.set("OPENAI_API_KEY", "test-openai-key")
- },
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["anthropic"]).toBeDefined()
- expect(providers["openai"]).toBeUndefined()
- },
- })
- })
- test("model whitelist filters models for provider", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- anthropic: {
- whitelist: ["claude-sonnet-4-20250514"],
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["anthropic"]).toBeDefined()
- const models = Object.keys(providers["anthropic"].info.models)
- expect(models).toContain("claude-sonnet-4-20250514")
- expect(models.length).toBe(1)
- },
- })
- })
- test("model blacklist excludes specific models", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- anthropic: {
- blacklist: ["claude-sonnet-4-20250514"],
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["anthropic"]).toBeDefined()
- const models = Object.keys(providers["anthropic"].info.models)
- expect(models).not.toContain("claude-sonnet-4-20250514")
- },
- })
- })
- test("custom model alias via config", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- anthropic: {
- models: {
- "my-alias": {
- id: "claude-sonnet-4-20250514",
- name: "My Custom Alias",
- },
- },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["anthropic"]).toBeDefined()
- expect(providers["anthropic"].info.models["my-alias"]).toBeDefined()
- expect(providers["anthropic"].info.models["my-alias"].name).toBe("My Custom Alias")
- },
- })
- })
- test("custom provider with npm package", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- "custom-provider": {
- name: "Custom Provider",
- npm: "@ai-sdk/openai-compatible",
- api: "https://api.custom.com/v1",
- env: ["CUSTOM_API_KEY"],
- models: {
- "custom-model": {
- name: "Custom Model",
- tool_call: true,
- limit: {
- context: 128000,
- output: 4096,
- },
- },
- },
- options: {
- apiKey: "custom-key",
- },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["custom-provider"]).toBeDefined()
- expect(providers["custom-provider"].info.name).toBe("Custom Provider")
- expect(providers["custom-provider"].info.models["custom-model"]).toBeDefined()
- },
- })
- })
- test("env variable takes precedence, config merges options", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- anthropic: {
- options: {
- timeout: 60000,
- },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "env-api-key")
- },
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["anthropic"]).toBeDefined()
- // Config options should be merged
- expect(providers["anthropic"].options.timeout).toBe(60000)
- },
- })
- })
- test("getModel returns model for valid provider/model", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- const model = await Provider.getModel("anthropic", "claude-sonnet-4-20250514")
- expect(model).toBeDefined()
- expect(model.providerID).toBe("anthropic")
- expect(model.modelID).toBe("claude-sonnet-4-20250514")
- expect(model.language).toBeDefined()
- },
- })
- })
- test("getModel throws ModelNotFoundError for invalid model", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- expect(Provider.getModel("anthropic", "nonexistent-model")).rejects.toThrow()
- },
- })
- })
- test("getModel throws ModelNotFoundError for invalid provider", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- expect(Provider.getModel("nonexistent-provider", "some-model")).rejects.toThrow()
- },
- })
- })
- test("parseModel correctly parses provider/model string", () => {
- const result = Provider.parseModel("anthropic/claude-sonnet-4")
- expect(result.providerID).toBe("anthropic")
- expect(result.modelID).toBe("claude-sonnet-4")
- })
- test("parseModel handles model IDs with slashes", () => {
- const result = Provider.parseModel("openrouter/anthropic/claude-3-opus")
- expect(result.providerID).toBe("openrouter")
- expect(result.modelID).toBe("anthropic/claude-3-opus")
- })
- test("defaultModel returns first available model when no config set", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- const model = await Provider.defaultModel()
- expect(model.providerID).toBeDefined()
- expect(model.modelID).toBeDefined()
- },
- })
- })
- test("defaultModel respects config model setting", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- model: "anthropic/claude-sonnet-4-20250514",
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- const model = await Provider.defaultModel()
- expect(model.providerID).toBe("anthropic")
- expect(model.modelID).toBe("claude-sonnet-4-20250514")
- },
- })
- })
- test("provider with baseURL from config", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- "custom-openai": {
- name: "Custom OpenAI",
- npm: "@ai-sdk/openai-compatible",
- env: [],
- models: {
- "gpt-4": {
- name: "GPT-4",
- tool_call: true,
- limit: { context: 128000, output: 4096 },
- },
- },
- options: {
- apiKey: "test-key",
- baseURL: "https://custom.openai.com/v1",
- },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["custom-openai"]).toBeDefined()
- expect(providers["custom-openai"].options.baseURL).toBe("https://custom.openai.com/v1")
- },
- })
- })
- test("model cost defaults to zero when not specified", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- "test-provider": {
- name: "Test Provider",
- npm: "@ai-sdk/openai-compatible",
- env: [],
- models: {
- "test-model": {
- name: "Test Model",
- tool_call: true,
- limit: { context: 128000, output: 4096 },
- },
- },
- options: {
- apiKey: "test-key",
- },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const providers = await Provider.list()
- const model = providers["test-provider"].info.models["test-model"]
- expect(model.cost.input).toBe(0)
- expect(model.cost.output).toBe(0)
- expect(model.cost.cache_read).toBe(0)
- expect(model.cost.cache_write).toBe(0)
- },
- })
- })
- test("model options are merged from existing model", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- anthropic: {
- models: {
- "claude-sonnet-4-20250514": {
- options: {
- customOption: "custom-value",
- },
- },
- },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- const providers = await Provider.list()
- const model = providers["anthropic"].info.models["claude-sonnet-4-20250514"]
- expect(model.options.customOption).toBe("custom-value")
- },
- })
- })
- test("provider removed when all models filtered out", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- anthropic: {
- whitelist: ["nonexistent-model"],
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["anthropic"]).toBeUndefined()
- },
- })
- })
- test("closest finds model by partial match", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- const result = await Provider.closest("anthropic", ["sonnet-4"])
- expect(result).toBeDefined()
- expect(result?.providerID).toBe("anthropic")
- expect(result?.modelID).toContain("sonnet-4")
- },
- })
- })
- test("closest returns undefined for nonexistent provider", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const result = await Provider.closest("nonexistent", ["model"])
- expect(result).toBeUndefined()
- },
- })
- })
- test("getModel uses realIdByKey for aliased models", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- anthropic: {
- models: {
- "my-sonnet": {
- id: "claude-sonnet-4-20250514",
- name: "My Sonnet Alias",
- },
- },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["anthropic"].info.models["my-sonnet"]).toBeDefined()
- const model = await Provider.getModel("anthropic", "my-sonnet")
- expect(model).toBeDefined()
- expect(model.modelID).toBe("my-sonnet")
- expect(model.info.name).toBe("My Sonnet Alias")
- },
- })
- })
- test("provider api field sets default baseURL", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- "custom-api": {
- name: "Custom API",
- npm: "@ai-sdk/openai-compatible",
- api: "https://api.example.com/v1",
- env: [],
- models: {
- "model-1": {
- name: "Model 1",
- tool_call: true,
- limit: { context: 8000, output: 2000 },
- },
- },
- options: {
- apiKey: "test-key",
- },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["custom-api"].options.baseURL).toBe("https://api.example.com/v1")
- },
- })
- })
- test("explicit baseURL overrides api field", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- "custom-api": {
- name: "Custom API",
- npm: "@ai-sdk/openai-compatible",
- api: "https://api.example.com/v1",
- env: [],
- models: {
- "model-1": {
- name: "Model 1",
- tool_call: true,
- limit: { context: 8000, output: 2000 },
- },
- },
- options: {
- apiKey: "test-key",
- baseURL: "https://custom.override.com/v1",
- },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["custom-api"].options.baseURL).toBe("https://custom.override.com/v1")
- },
- })
- })
- test("model inherits properties from existing database model", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- anthropic: {
- models: {
- "claude-sonnet-4-20250514": {
- name: "Custom Name for Sonnet",
- },
- },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- const providers = await Provider.list()
- const model = providers["anthropic"].info.models["claude-sonnet-4-20250514"]
- expect(model.name).toBe("Custom Name for Sonnet")
- expect(model.tool_call).toBe(true)
- expect(model.attachment).toBe(true)
- expect(model.limit.context).toBeGreaterThan(0)
- },
- })
- })
- test("disabled_providers prevents loading even with env var", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- disabled_providers: ["openai"],
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("OPENAI_API_KEY", "test-openai-key")
- },
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["openai"]).toBeUndefined()
- },
- })
- })
- test("enabled_providers with empty array allows no providers", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- enabled_providers: [],
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- Env.set("OPENAI_API_KEY", "test-openai-key")
- },
- fn: async () => {
- const providers = await Provider.list()
- expect(Object.keys(providers).length).toBe(0)
- },
- })
- })
- test("whitelist and blacklist can be combined", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- anthropic: {
- whitelist: ["claude-sonnet-4-20250514", "claude-opus-4-20250514"],
- blacklist: ["claude-opus-4-20250514"],
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["anthropic"]).toBeDefined()
- const models = Object.keys(providers["anthropic"].info.models)
- expect(models).toContain("claude-sonnet-4-20250514")
- expect(models).not.toContain("claude-opus-4-20250514")
- expect(models.length).toBe(1)
- },
- })
- })
- test("model modalities default correctly", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- "test-provider": {
- name: "Test",
- npm: "@ai-sdk/openai-compatible",
- env: [],
- models: {
- "test-model": {
- name: "Test Model",
- tool_call: true,
- limit: { context: 8000, output: 2000 },
- },
- },
- options: { apiKey: "test" },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const providers = await Provider.list()
- const model = providers["test-provider"].info.models["test-model"]
- expect(model.modalities).toEqual({
- input: ["text"],
- output: ["text"],
- })
- },
- })
- })
- test("model with custom cost values", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- "test-provider": {
- name: "Test",
- npm: "@ai-sdk/openai-compatible",
- env: [],
- models: {
- "test-model": {
- name: "Test Model",
- tool_call: true,
- limit: { context: 8000, output: 2000 },
- cost: {
- input: 5,
- output: 15,
- cache_read: 2.5,
- cache_write: 7.5,
- },
- },
- },
- options: { apiKey: "test" },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const providers = await Provider.list()
- const model = providers["test-provider"].info.models["test-model"]
- expect(model.cost.input).toBe(5)
- expect(model.cost.output).toBe(15)
- expect(model.cost.cache_read).toBe(2.5)
- expect(model.cost.cache_write).toBe(7.5)
- },
- })
- })
- test("getSmallModel returns appropriate small model", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- const model = await Provider.getSmallModel("anthropic")
- expect(model).toBeDefined()
- expect(model?.modelID).toContain("haiku")
- },
- })
- })
- test("getSmallModel respects config small_model override", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- small_model: "anthropic/claude-sonnet-4-20250514",
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- const model = await Provider.getSmallModel("anthropic")
- expect(model).toBeDefined()
- expect(model?.providerID).toBe("anthropic")
- expect(model?.modelID).toBe("claude-sonnet-4-20250514")
- },
- })
- })
- test("provider.sort prioritizes preferred models", () => {
- const models = [
- { id: "random-model", name: "Random" },
- { id: "claude-sonnet-4-latest", name: "Claude Sonnet 4" },
- { id: "gpt-5-turbo", name: "GPT-5 Turbo" },
- { id: "other-model", name: "Other" },
- ] as any[]
- const sorted = Provider.sort(models)
- expect(sorted[0].id).toContain("sonnet-4")
- expect(sorted[0].id).toContain("latest")
- expect(sorted[sorted.length - 1].id).not.toContain("gpt-5")
- expect(sorted[sorted.length - 1].id).not.toContain("sonnet-4")
- })
- test("multiple providers can be configured simultaneously", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- anthropic: {
- options: { timeout: 30000 },
- },
- openai: {
- options: { timeout: 60000 },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-anthropic-key")
- Env.set("OPENAI_API_KEY", "test-openai-key")
- },
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["anthropic"]).toBeDefined()
- expect(providers["openai"]).toBeDefined()
- expect(providers["anthropic"].options.timeout).toBe(30000)
- expect(providers["openai"].options.timeout).toBe(60000)
- },
- })
- })
- test("provider with custom npm package", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- "local-llm": {
- name: "Local LLM",
- npm: "@ai-sdk/openai-compatible",
- env: [],
- models: {
- "llama-3": {
- name: "Llama 3",
- tool_call: true,
- limit: { context: 8192, output: 2048 },
- },
- },
- options: {
- apiKey: "not-needed",
- baseURL: "http://localhost:11434/v1",
- },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["local-llm"]).toBeDefined()
- expect(providers["local-llm"].info.npm).toBe("@ai-sdk/openai-compatible")
- expect(providers["local-llm"].options.baseURL).toBe("http://localhost:11434/v1")
- },
- })
- })
- // Edge cases for model configuration
- test("model alias name defaults to alias key when id differs", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- anthropic: {
- models: {
- sonnet: {
- id: "claude-sonnet-4-20250514",
- // no name specified - should default to "sonnet" (the key)
- },
- },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["anthropic"].info.models["sonnet"].name).toBe("sonnet")
- },
- })
- })
- test("provider with multiple env var options only includes apiKey when single env", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- "multi-env": {
- name: "Multi Env Provider",
- npm: "@ai-sdk/openai-compatible",
- env: ["MULTI_ENV_KEY_1", "MULTI_ENV_KEY_2"],
- models: {
- "model-1": {
- name: "Model 1",
- tool_call: true,
- limit: { context: 8000, output: 2000 },
- },
- },
- options: {
- baseURL: "https://api.example.com/v1",
- },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("MULTI_ENV_KEY_1", "test-key")
- },
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["multi-env"]).toBeDefined()
- // When multiple env options exist, apiKey should NOT be auto-set
- expect(providers["multi-env"].options.apiKey).toBeUndefined()
- },
- })
- })
- test("provider with single env var includes apiKey automatically", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- "single-env": {
- name: "Single Env Provider",
- npm: "@ai-sdk/openai-compatible",
- env: ["SINGLE_ENV_KEY"],
- models: {
- "model-1": {
- name: "Model 1",
- tool_call: true,
- limit: { context: 8000, output: 2000 },
- },
- },
- options: {
- baseURL: "https://api.example.com/v1",
- },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("SINGLE_ENV_KEY", "my-api-key")
- },
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["single-env"]).toBeDefined()
- // Single env option should auto-set apiKey
- expect(providers["single-env"].options.apiKey).toBe("my-api-key")
- },
- })
- })
- test("model cost overrides existing cost values", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- anthropic: {
- models: {
- "claude-sonnet-4-20250514": {
- cost: {
- input: 999,
- output: 888,
- },
- },
- },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- const providers = await Provider.list()
- const model = providers["anthropic"].info.models["claude-sonnet-4-20250514"]
- expect(model.cost.input).toBe(999)
- expect(model.cost.output).toBe(888)
- },
- })
- })
- test("completely new provider not in database can be configured", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- "brand-new-provider": {
- name: "Brand New",
- npm: "@ai-sdk/openai-compatible",
- env: [],
- api: "https://new-api.com/v1",
- models: {
- "new-model": {
- name: "New Model",
- tool_call: true,
- reasoning: true,
- attachment: true,
- temperature: true,
- limit: { context: 32000, output: 8000 },
- modalities: {
- input: ["text", "image"],
- output: ["text"],
- },
- },
- },
- options: {
- apiKey: "new-key",
- },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["brand-new-provider"]).toBeDefined()
- expect(providers["brand-new-provider"].info.name).toBe("Brand New")
- const model = providers["brand-new-provider"].info.models["new-model"]
- expect(model.reasoning).toBe(true)
- expect(model.attachment).toBe(true)
- expect(model.modalities?.input).toContain("image")
- },
- })
- })
- test("disabled_providers and enabled_providers interaction", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- // enabled_providers takes precedence - only these are considered
- enabled_providers: ["anthropic", "openai"],
- // Then disabled_providers filters from the enabled set
- disabled_providers: ["openai"],
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-anthropic")
- Env.set("OPENAI_API_KEY", "test-openai")
- Env.set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google")
- },
- fn: async () => {
- const providers = await Provider.list()
- // anthropic: in enabled, not in disabled = allowed
- expect(providers["anthropic"]).toBeDefined()
- // openai: in enabled, but also in disabled = NOT allowed
- expect(providers["openai"]).toBeUndefined()
- // google: not in enabled = NOT allowed (even though not disabled)
- expect(providers["google"]).toBeUndefined()
- },
- })
- })
- test("model with tool_call false", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- "no-tools": {
- name: "No Tools Provider",
- npm: "@ai-sdk/openai-compatible",
- env: [],
- models: {
- "basic-model": {
- name: "Basic Model",
- tool_call: false,
- limit: { context: 4000, output: 1000 },
- },
- },
- options: { apiKey: "test" },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["no-tools"].info.models["basic-model"].tool_call).toBe(false)
- },
- })
- })
- test("model defaults tool_call to true when not specified", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- "default-tools": {
- name: "Default Tools Provider",
- npm: "@ai-sdk/openai-compatible",
- env: [],
- models: {
- model: {
- name: "Model",
- // tool_call not specified
- limit: { context: 4000, output: 1000 },
- },
- },
- options: { apiKey: "test" },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["default-tools"].info.models["model"].tool_call).toBe(true)
- },
- })
- })
- test("model headers are preserved", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- "headers-provider": {
- name: "Headers Provider",
- npm: "@ai-sdk/openai-compatible",
- env: [],
- models: {
- model: {
- name: "Model",
- tool_call: true,
- limit: { context: 4000, output: 1000 },
- headers: {
- "X-Custom-Header": "custom-value",
- Authorization: "Bearer special-token",
- },
- },
- },
- options: { apiKey: "test" },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const providers = await Provider.list()
- const model = providers["headers-provider"].info.models["model"]
- expect(model.headers).toEqual({
- "X-Custom-Header": "custom-value",
- Authorization: "Bearer special-token",
- })
- },
- })
- })
- test("provider env fallback - second env var used if first missing", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- "fallback-env": {
- name: "Fallback Env Provider",
- npm: "@ai-sdk/openai-compatible",
- env: ["PRIMARY_KEY", "FALLBACK_KEY"],
- models: {
- model: {
- name: "Model",
- tool_call: true,
- limit: { context: 4000, output: 1000 },
- },
- },
- options: { baseURL: "https://api.example.com" },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- // Only set fallback, not primary
- Env.set("FALLBACK_KEY", "fallback-api-key")
- },
- fn: async () => {
- const providers = await Provider.list()
- // Provider should load because fallback env var is set
- expect(providers["fallback-env"]).toBeDefined()
- },
- })
- })
- test("getModel returns consistent results", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- const model1 = await Provider.getModel("anthropic", "claude-sonnet-4-20250514")
- const model2 = await Provider.getModel("anthropic", "claude-sonnet-4-20250514")
- expect(model1.providerID).toEqual(model2.providerID)
- expect(model1.modelID).toEqual(model2.modelID)
- expect(model1.info).toEqual(model2.info)
- },
- })
- })
- test("provider name defaults to id when not in database", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- "my-custom-id": {
- // no name specified
- npm: "@ai-sdk/openai-compatible",
- env: [],
- models: {
- model: {
- name: "Model",
- tool_call: true,
- limit: { context: 4000, output: 1000 },
- },
- },
- options: { apiKey: "test" },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const providers = await Provider.list()
- expect(providers["my-custom-id"].info.name).toBe("my-custom-id")
- },
- })
- })
- test("ModelNotFoundError includes suggestions for typos", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- try {
- await Provider.getModel("anthropic", "claude-sonet-4") // typo: sonet instead of sonnet
- expect(true).toBe(false) // Should not reach here
- } catch (e: any) {
- expect(e.data.suggestions).toBeDefined()
- expect(e.data.suggestions.length).toBeGreaterThan(0)
- }
- },
- })
- })
- test("ModelNotFoundError for provider includes suggestions", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- try {
- await Provider.getModel("antropic", "claude-sonnet-4") // typo: antropic
- expect(true).toBe(false) // Should not reach here
- } catch (e: any) {
- expect(e.data.suggestions).toBeDefined()
- expect(e.data.suggestions).toContain("anthropic")
- }
- },
- })
- })
- test("getProvider returns undefined for nonexistent provider", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const provider = await Provider.getProvider("nonexistent")
- expect(provider).toBeUndefined()
- },
- })
- })
- test("getProvider returns provider info", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- const provider = await Provider.getProvider("anthropic")
- expect(provider).toBeDefined()
- expect(provider?.info.id).toBe("anthropic")
- },
- })
- })
- test("closest returns undefined when no partial match found", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- const result = await Provider.closest("anthropic", ["nonexistent-xyz-model"])
- expect(result).toBeUndefined()
- },
- })
- })
- test("closest checks multiple query terms in order", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- // First term won't match, second will
- const result = await Provider.closest("anthropic", ["nonexistent", "haiku"])
- expect(result).toBeDefined()
- expect(result?.modelID).toContain("haiku")
- },
- })
- })
- test("model limit defaults to zero when not specified", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- "no-limit": {
- name: "No Limit Provider",
- npm: "@ai-sdk/openai-compatible",
- env: [],
- models: {
- model: {
- name: "Model",
- tool_call: true,
- // no limit specified
- },
- },
- options: { apiKey: "test" },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const providers = await Provider.list()
- const model = providers["no-limit"].info.models["model"]
- expect(model.limit.context).toBe(0)
- expect(model.limit.output).toBe(0)
- },
- })
- })
- test("provider options are deeply merged", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- provider: {
- anthropic: {
- options: {
- headers: {
- "X-Custom": "custom-value",
- },
- timeout: 30000,
- },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- init: async () => {
- Env.set("ANTHROPIC_API_KEY", "test-api-key")
- },
- fn: async () => {
- const providers = await Provider.list()
- // Custom options should be merged
- expect(providers["anthropic"].options.timeout).toBe(30000)
- expect(providers["anthropic"].options.headers["X-Custom"]).toBe("custom-value")
- // anthropic custom loader adds its own headers, they should coexist
- expect(providers["anthropic"].options.headers["anthropic-beta"]).toBeDefined()
- },
- })
- })
|