provider.test.ts 60 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149
  1. import { test, expect, mock } from "bun:test"
  2. import path from "path"
  3. // Mock BunProc and default plugins to prevent actual installations during tests
  4. mock.module("../../src/bun/index", () => ({
  5. BunProc: {
  6. install: async (pkg: string, _version?: string) => {
  7. // Return package name without version for mocking
  8. const lastAtIndex = pkg.lastIndexOf("@")
  9. return lastAtIndex > 0 ? pkg.substring(0, lastAtIndex) : pkg
  10. },
  11. run: async () => {
  12. throw new Error("BunProc.run should not be called in tests")
  13. },
  14. which: () => process.execPath,
  15. InstallFailedError: class extends Error {},
  16. },
  17. }))
  18. const mockPlugin = () => ({})
  19. mock.module("opencode-copilot-auth", () => ({ default: mockPlugin }))
  20. mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin }))
  21. mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: mockPlugin }))
  22. import { tmpdir } from "../fixture/fixture"
  23. import { Instance } from "../../src/project/instance"
  24. import { Provider } from "../../src/provider/provider"
  25. import { Env } from "../../src/env"
  26. test("provider loaded from env variable", async () => {
  27. await using tmp = await tmpdir({
  28. init: async (dir) => {
  29. await Bun.write(
  30. path.join(dir, "opencode.json"),
  31. JSON.stringify({
  32. $schema: "https://opencode.ai/config.json",
  33. }),
  34. )
  35. },
  36. })
  37. await Instance.provide({
  38. directory: tmp.path,
  39. init: async () => {
  40. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  41. },
  42. fn: async () => {
  43. const providers = await Provider.list()
  44. expect(providers["anthropic"]).toBeDefined()
  45. // Note: source becomes "custom" because CUSTOM_LOADERS run after env loading
  46. // and anthropic has a custom loader that merges additional options
  47. expect(providers["anthropic"].source).toBe("custom")
  48. },
  49. })
  50. })
  51. test("provider loaded from config with apiKey option", async () => {
  52. await using tmp = await tmpdir({
  53. init: async (dir) => {
  54. await Bun.write(
  55. path.join(dir, "opencode.json"),
  56. JSON.stringify({
  57. $schema: "https://opencode.ai/config.json",
  58. provider: {
  59. anthropic: {
  60. options: {
  61. apiKey: "config-api-key",
  62. },
  63. },
  64. },
  65. }),
  66. )
  67. },
  68. })
  69. await Instance.provide({
  70. directory: tmp.path,
  71. fn: async () => {
  72. const providers = await Provider.list()
  73. expect(providers["anthropic"]).toBeDefined()
  74. },
  75. })
  76. })
  77. test("disabled_providers excludes provider", async () => {
  78. await using tmp = await tmpdir({
  79. init: async (dir) => {
  80. await Bun.write(
  81. path.join(dir, "opencode.json"),
  82. JSON.stringify({
  83. $schema: "https://opencode.ai/config.json",
  84. disabled_providers: ["anthropic"],
  85. }),
  86. )
  87. },
  88. })
  89. await Instance.provide({
  90. directory: tmp.path,
  91. init: async () => {
  92. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  93. },
  94. fn: async () => {
  95. const providers = await Provider.list()
  96. expect(providers["anthropic"]).toBeUndefined()
  97. },
  98. })
  99. })
  100. test("enabled_providers restricts to only listed providers", async () => {
  101. await using tmp = await tmpdir({
  102. init: async (dir) => {
  103. await Bun.write(
  104. path.join(dir, "opencode.json"),
  105. JSON.stringify({
  106. $schema: "https://opencode.ai/config.json",
  107. enabled_providers: ["anthropic"],
  108. }),
  109. )
  110. },
  111. })
  112. await Instance.provide({
  113. directory: tmp.path,
  114. init: async () => {
  115. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  116. Env.set("OPENAI_API_KEY", "test-openai-key")
  117. },
  118. fn: async () => {
  119. const providers = await Provider.list()
  120. expect(providers["anthropic"]).toBeDefined()
  121. expect(providers["openai"]).toBeUndefined()
  122. },
  123. })
  124. })
  125. test("model whitelist filters models for provider", async () => {
  126. await using tmp = await tmpdir({
  127. init: async (dir) => {
  128. await Bun.write(
  129. path.join(dir, "opencode.json"),
  130. JSON.stringify({
  131. $schema: "https://opencode.ai/config.json",
  132. provider: {
  133. anthropic: {
  134. whitelist: ["claude-sonnet-4-20250514"],
  135. },
  136. },
  137. }),
  138. )
  139. },
  140. })
  141. await Instance.provide({
  142. directory: tmp.path,
  143. init: async () => {
  144. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  145. },
  146. fn: async () => {
  147. const providers = await Provider.list()
  148. expect(providers["anthropic"]).toBeDefined()
  149. const models = Object.keys(providers["anthropic"].models)
  150. expect(models).toContain("claude-sonnet-4-20250514")
  151. expect(models.length).toBe(1)
  152. },
  153. })
  154. })
  155. test("model blacklist excludes specific models", async () => {
  156. await using tmp = await tmpdir({
  157. init: async (dir) => {
  158. await Bun.write(
  159. path.join(dir, "opencode.json"),
  160. JSON.stringify({
  161. $schema: "https://opencode.ai/config.json",
  162. provider: {
  163. anthropic: {
  164. blacklist: ["claude-sonnet-4-20250514"],
  165. },
  166. },
  167. }),
  168. )
  169. },
  170. })
  171. await Instance.provide({
  172. directory: tmp.path,
  173. init: async () => {
  174. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  175. },
  176. fn: async () => {
  177. const providers = await Provider.list()
  178. expect(providers["anthropic"]).toBeDefined()
  179. const models = Object.keys(providers["anthropic"].models)
  180. expect(models).not.toContain("claude-sonnet-4-20250514")
  181. },
  182. })
  183. })
  184. test("custom model alias via config", async () => {
  185. await using tmp = await tmpdir({
  186. init: async (dir) => {
  187. await Bun.write(
  188. path.join(dir, "opencode.json"),
  189. JSON.stringify({
  190. $schema: "https://opencode.ai/config.json",
  191. provider: {
  192. anthropic: {
  193. models: {
  194. "my-alias": {
  195. id: "claude-sonnet-4-20250514",
  196. name: "My Custom Alias",
  197. },
  198. },
  199. },
  200. },
  201. }),
  202. )
  203. },
  204. })
  205. await Instance.provide({
  206. directory: tmp.path,
  207. init: async () => {
  208. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  209. },
  210. fn: async () => {
  211. const providers = await Provider.list()
  212. expect(providers["anthropic"]).toBeDefined()
  213. expect(providers["anthropic"].models["my-alias"]).toBeDefined()
  214. expect(providers["anthropic"].models["my-alias"].name).toBe("My Custom Alias")
  215. },
  216. })
  217. })
  218. test("custom provider with npm package", async () => {
  219. await using tmp = await tmpdir({
  220. init: async (dir) => {
  221. await Bun.write(
  222. path.join(dir, "opencode.json"),
  223. JSON.stringify({
  224. $schema: "https://opencode.ai/config.json",
  225. provider: {
  226. "custom-provider": {
  227. name: "Custom Provider",
  228. npm: "@ai-sdk/openai-compatible",
  229. api: "https://api.custom.com/v1",
  230. env: ["CUSTOM_API_KEY"],
  231. models: {
  232. "custom-model": {
  233. name: "Custom Model",
  234. tool_call: true,
  235. limit: {
  236. context: 128000,
  237. output: 4096,
  238. },
  239. },
  240. },
  241. options: {
  242. apiKey: "custom-key",
  243. },
  244. },
  245. },
  246. }),
  247. )
  248. },
  249. })
  250. await Instance.provide({
  251. directory: tmp.path,
  252. fn: async () => {
  253. const providers = await Provider.list()
  254. expect(providers["custom-provider"]).toBeDefined()
  255. expect(providers["custom-provider"].name).toBe("Custom Provider")
  256. expect(providers["custom-provider"].models["custom-model"]).toBeDefined()
  257. },
  258. })
  259. })
  260. test("env variable takes precedence, config merges options", async () => {
  261. await using tmp = await tmpdir({
  262. init: async (dir) => {
  263. await Bun.write(
  264. path.join(dir, "opencode.json"),
  265. JSON.stringify({
  266. $schema: "https://opencode.ai/config.json",
  267. provider: {
  268. anthropic: {
  269. options: {
  270. timeout: 60000,
  271. },
  272. },
  273. },
  274. }),
  275. )
  276. },
  277. })
  278. await Instance.provide({
  279. directory: tmp.path,
  280. init: async () => {
  281. Env.set("ANTHROPIC_API_KEY", "env-api-key")
  282. },
  283. fn: async () => {
  284. const providers = await Provider.list()
  285. expect(providers["anthropic"]).toBeDefined()
  286. // Config options should be merged
  287. expect(providers["anthropic"].options.timeout).toBe(60000)
  288. },
  289. })
  290. })
  291. test("getModel returns model for valid provider/model", async () => {
  292. await using tmp = await tmpdir({
  293. init: async (dir) => {
  294. await Bun.write(
  295. path.join(dir, "opencode.json"),
  296. JSON.stringify({
  297. $schema: "https://opencode.ai/config.json",
  298. }),
  299. )
  300. },
  301. })
  302. await Instance.provide({
  303. directory: tmp.path,
  304. init: async () => {
  305. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  306. },
  307. fn: async () => {
  308. const model = await Provider.getModel("anthropic", "claude-sonnet-4-20250514")
  309. expect(model).toBeDefined()
  310. expect(model.providerID).toBe("anthropic")
  311. expect(model.id).toBe("claude-sonnet-4-20250514")
  312. const language = await Provider.getLanguage(model)
  313. expect(language).toBeDefined()
  314. },
  315. })
  316. })
  317. test("getModel throws ModelNotFoundError for invalid model", async () => {
  318. await using tmp = await tmpdir({
  319. init: async (dir) => {
  320. await Bun.write(
  321. path.join(dir, "opencode.json"),
  322. JSON.stringify({
  323. $schema: "https://opencode.ai/config.json",
  324. }),
  325. )
  326. },
  327. })
  328. await Instance.provide({
  329. directory: tmp.path,
  330. init: async () => {
  331. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  332. },
  333. fn: async () => {
  334. expect(Provider.getModel("anthropic", "nonexistent-model")).rejects.toThrow()
  335. },
  336. })
  337. })
  338. test("getModel throws ModelNotFoundError for invalid provider", async () => {
  339. await using tmp = await tmpdir({
  340. init: async (dir) => {
  341. await Bun.write(
  342. path.join(dir, "opencode.json"),
  343. JSON.stringify({
  344. $schema: "https://opencode.ai/config.json",
  345. }),
  346. )
  347. },
  348. })
  349. await Instance.provide({
  350. directory: tmp.path,
  351. fn: async () => {
  352. expect(Provider.getModel("nonexistent-provider", "some-model")).rejects.toThrow()
  353. },
  354. })
  355. })
  356. test("parseModel correctly parses provider/model string", () => {
  357. const result = Provider.parseModel("anthropic/claude-sonnet-4")
  358. expect(result.providerID).toBe("anthropic")
  359. expect(result.modelID).toBe("claude-sonnet-4")
  360. })
  361. test("parseModel handles model IDs with slashes", () => {
  362. const result = Provider.parseModel("openrouter/anthropic/claude-3-opus")
  363. expect(result.providerID).toBe("openrouter")
  364. expect(result.modelID).toBe("anthropic/claude-3-opus")
  365. })
  366. test("defaultModel returns first available model when no config set", async () => {
  367. await using tmp = await tmpdir({
  368. init: async (dir) => {
  369. await Bun.write(
  370. path.join(dir, "opencode.json"),
  371. JSON.stringify({
  372. $schema: "https://opencode.ai/config.json",
  373. }),
  374. )
  375. },
  376. })
  377. await Instance.provide({
  378. directory: tmp.path,
  379. init: async () => {
  380. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  381. },
  382. fn: async () => {
  383. const model = await Provider.defaultModel()
  384. expect(model.providerID).toBeDefined()
  385. expect(model.modelID).toBeDefined()
  386. },
  387. })
  388. })
  389. test("defaultModel respects config model setting", async () => {
  390. await using tmp = await tmpdir({
  391. init: async (dir) => {
  392. await Bun.write(
  393. path.join(dir, "opencode.json"),
  394. JSON.stringify({
  395. $schema: "https://opencode.ai/config.json",
  396. model: "anthropic/claude-sonnet-4-20250514",
  397. }),
  398. )
  399. },
  400. })
  401. await Instance.provide({
  402. directory: tmp.path,
  403. init: async () => {
  404. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  405. },
  406. fn: async () => {
  407. const model = await Provider.defaultModel()
  408. expect(model.providerID).toBe("anthropic")
  409. expect(model.modelID).toBe("claude-sonnet-4-20250514")
  410. },
  411. })
  412. })
  413. test("provider with baseURL from config", async () => {
  414. await using tmp = await tmpdir({
  415. init: async (dir) => {
  416. await Bun.write(
  417. path.join(dir, "opencode.json"),
  418. JSON.stringify({
  419. $schema: "https://opencode.ai/config.json",
  420. provider: {
  421. "custom-openai": {
  422. name: "Custom OpenAI",
  423. npm: "@ai-sdk/openai-compatible",
  424. env: [],
  425. models: {
  426. "gpt-4": {
  427. name: "GPT-4",
  428. tool_call: true,
  429. limit: { context: 128000, output: 4096 },
  430. },
  431. },
  432. options: {
  433. apiKey: "test-key",
  434. baseURL: "https://custom.openai.com/v1",
  435. },
  436. },
  437. },
  438. }),
  439. )
  440. },
  441. })
  442. await Instance.provide({
  443. directory: tmp.path,
  444. fn: async () => {
  445. const providers = await Provider.list()
  446. expect(providers["custom-openai"]).toBeDefined()
  447. expect(providers["custom-openai"].options.baseURL).toBe("https://custom.openai.com/v1")
  448. },
  449. })
  450. })
  451. test("model cost defaults to zero when not specified", async () => {
  452. await using tmp = await tmpdir({
  453. init: async (dir) => {
  454. await Bun.write(
  455. path.join(dir, "opencode.json"),
  456. JSON.stringify({
  457. $schema: "https://opencode.ai/config.json",
  458. provider: {
  459. "test-provider": {
  460. name: "Test Provider",
  461. npm: "@ai-sdk/openai-compatible",
  462. env: [],
  463. models: {
  464. "test-model": {
  465. name: "Test Model",
  466. tool_call: true,
  467. limit: { context: 128000, output: 4096 },
  468. },
  469. },
  470. options: {
  471. apiKey: "test-key",
  472. },
  473. },
  474. },
  475. }),
  476. )
  477. },
  478. })
  479. await Instance.provide({
  480. directory: tmp.path,
  481. fn: async () => {
  482. const providers = await Provider.list()
  483. const model = providers["test-provider"].models["test-model"]
  484. expect(model.cost.input).toBe(0)
  485. expect(model.cost.output).toBe(0)
  486. expect(model.cost.cache.read).toBe(0)
  487. expect(model.cost.cache.write).toBe(0)
  488. },
  489. })
  490. })
  491. test("model options are merged from existing model", async () => {
  492. await using tmp = await tmpdir({
  493. init: async (dir) => {
  494. await Bun.write(
  495. path.join(dir, "opencode.json"),
  496. JSON.stringify({
  497. $schema: "https://opencode.ai/config.json",
  498. provider: {
  499. anthropic: {
  500. models: {
  501. "claude-sonnet-4-20250514": {
  502. options: {
  503. customOption: "custom-value",
  504. },
  505. },
  506. },
  507. },
  508. },
  509. }),
  510. )
  511. },
  512. })
  513. await Instance.provide({
  514. directory: tmp.path,
  515. init: async () => {
  516. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  517. },
  518. fn: async () => {
  519. const providers = await Provider.list()
  520. const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
  521. expect(model.options.customOption).toBe("custom-value")
  522. },
  523. })
  524. })
  525. test("provider removed when all models filtered out", async () => {
  526. await using tmp = await tmpdir({
  527. init: async (dir) => {
  528. await Bun.write(
  529. path.join(dir, "opencode.json"),
  530. JSON.stringify({
  531. $schema: "https://opencode.ai/config.json",
  532. provider: {
  533. anthropic: {
  534. whitelist: ["nonexistent-model"],
  535. },
  536. },
  537. }),
  538. )
  539. },
  540. })
  541. await Instance.provide({
  542. directory: tmp.path,
  543. init: async () => {
  544. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  545. },
  546. fn: async () => {
  547. const providers = await Provider.list()
  548. expect(providers["anthropic"]).toBeUndefined()
  549. },
  550. })
  551. })
  552. test("closest finds model by partial match", async () => {
  553. await using tmp = await tmpdir({
  554. init: async (dir) => {
  555. await Bun.write(
  556. path.join(dir, "opencode.json"),
  557. JSON.stringify({
  558. $schema: "https://opencode.ai/config.json",
  559. }),
  560. )
  561. },
  562. })
  563. await Instance.provide({
  564. directory: tmp.path,
  565. init: async () => {
  566. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  567. },
  568. fn: async () => {
  569. const result = await Provider.closest("anthropic", ["sonnet-4"])
  570. expect(result).toBeDefined()
  571. expect(result?.providerID).toBe("anthropic")
  572. expect(result?.modelID).toContain("sonnet-4")
  573. },
  574. })
  575. })
  576. test("closest returns undefined for nonexistent provider", async () => {
  577. await using tmp = await tmpdir({
  578. init: async (dir) => {
  579. await Bun.write(
  580. path.join(dir, "opencode.json"),
  581. JSON.stringify({
  582. $schema: "https://opencode.ai/config.json",
  583. }),
  584. )
  585. },
  586. })
  587. await Instance.provide({
  588. directory: tmp.path,
  589. fn: async () => {
  590. const result = await Provider.closest("nonexistent", ["model"])
  591. expect(result).toBeUndefined()
  592. },
  593. })
  594. })
  595. test("getModel uses realIdByKey for aliased models", async () => {
  596. await using tmp = await tmpdir({
  597. init: async (dir) => {
  598. await Bun.write(
  599. path.join(dir, "opencode.json"),
  600. JSON.stringify({
  601. $schema: "https://opencode.ai/config.json",
  602. provider: {
  603. anthropic: {
  604. models: {
  605. "my-sonnet": {
  606. id: "claude-sonnet-4-20250514",
  607. name: "My Sonnet Alias",
  608. },
  609. },
  610. },
  611. },
  612. }),
  613. )
  614. },
  615. })
  616. await Instance.provide({
  617. directory: tmp.path,
  618. init: async () => {
  619. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  620. },
  621. fn: async () => {
  622. const providers = await Provider.list()
  623. expect(providers["anthropic"].models["my-sonnet"]).toBeDefined()
  624. const model = await Provider.getModel("anthropic", "my-sonnet")
  625. expect(model).toBeDefined()
  626. expect(model.id).toBe("my-sonnet")
  627. expect(model.name).toBe("My Sonnet Alias")
  628. },
  629. })
  630. })
  631. test("provider api field sets model api.url", async () => {
  632. await using tmp = await tmpdir({
  633. init: async (dir) => {
  634. await Bun.write(
  635. path.join(dir, "opencode.json"),
  636. JSON.stringify({
  637. $schema: "https://opencode.ai/config.json",
  638. provider: {
  639. "custom-api": {
  640. name: "Custom API",
  641. npm: "@ai-sdk/openai-compatible",
  642. api: "https://api.example.com/v1",
  643. env: [],
  644. models: {
  645. "model-1": {
  646. name: "Model 1",
  647. tool_call: true,
  648. limit: { context: 8000, output: 2000 },
  649. },
  650. },
  651. options: {
  652. apiKey: "test-key",
  653. },
  654. },
  655. },
  656. }),
  657. )
  658. },
  659. })
  660. await Instance.provide({
  661. directory: tmp.path,
  662. fn: async () => {
  663. const providers = await Provider.list()
  664. // api field is stored on model.api.url, used by getSDK to set baseURL
  665. expect(providers["custom-api"].models["model-1"].api.url).toBe("https://api.example.com/v1")
  666. },
  667. })
  668. })
  669. test("explicit baseURL overrides api field", async () => {
  670. await using tmp = await tmpdir({
  671. init: async (dir) => {
  672. await Bun.write(
  673. path.join(dir, "opencode.json"),
  674. JSON.stringify({
  675. $schema: "https://opencode.ai/config.json",
  676. provider: {
  677. "custom-api": {
  678. name: "Custom API",
  679. npm: "@ai-sdk/openai-compatible",
  680. api: "https://api.example.com/v1",
  681. env: [],
  682. models: {
  683. "model-1": {
  684. name: "Model 1",
  685. tool_call: true,
  686. limit: { context: 8000, output: 2000 },
  687. },
  688. },
  689. options: {
  690. apiKey: "test-key",
  691. baseURL: "https://custom.override.com/v1",
  692. },
  693. },
  694. },
  695. }),
  696. )
  697. },
  698. })
  699. await Instance.provide({
  700. directory: tmp.path,
  701. fn: async () => {
  702. const providers = await Provider.list()
  703. expect(providers["custom-api"].options.baseURL).toBe("https://custom.override.com/v1")
  704. },
  705. })
  706. })
  707. test("model inherits properties from existing database model", async () => {
  708. await using tmp = await tmpdir({
  709. init: async (dir) => {
  710. await Bun.write(
  711. path.join(dir, "opencode.json"),
  712. JSON.stringify({
  713. $schema: "https://opencode.ai/config.json",
  714. provider: {
  715. anthropic: {
  716. models: {
  717. "claude-sonnet-4-20250514": {
  718. name: "Custom Name for Sonnet",
  719. },
  720. },
  721. },
  722. },
  723. }),
  724. )
  725. },
  726. })
  727. await Instance.provide({
  728. directory: tmp.path,
  729. init: async () => {
  730. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  731. },
  732. fn: async () => {
  733. const providers = await Provider.list()
  734. const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
  735. expect(model.name).toBe("Custom Name for Sonnet")
  736. expect(model.capabilities.toolcall).toBe(true)
  737. expect(model.capabilities.attachment).toBe(true)
  738. expect(model.limit.context).toBeGreaterThan(0)
  739. },
  740. })
  741. })
  742. test("disabled_providers prevents loading even with env var", async () => {
  743. await using tmp = await tmpdir({
  744. init: async (dir) => {
  745. await Bun.write(
  746. path.join(dir, "opencode.json"),
  747. JSON.stringify({
  748. $schema: "https://opencode.ai/config.json",
  749. disabled_providers: ["openai"],
  750. }),
  751. )
  752. },
  753. })
  754. await Instance.provide({
  755. directory: tmp.path,
  756. init: async () => {
  757. Env.set("OPENAI_API_KEY", "test-openai-key")
  758. },
  759. fn: async () => {
  760. const providers = await Provider.list()
  761. expect(providers["openai"]).toBeUndefined()
  762. },
  763. })
  764. })
  765. test("enabled_providers with empty array allows no providers", async () => {
  766. await using tmp = await tmpdir({
  767. init: async (dir) => {
  768. await Bun.write(
  769. path.join(dir, "opencode.json"),
  770. JSON.stringify({
  771. $schema: "https://opencode.ai/config.json",
  772. enabled_providers: [],
  773. }),
  774. )
  775. },
  776. })
  777. await Instance.provide({
  778. directory: tmp.path,
  779. init: async () => {
  780. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  781. Env.set("OPENAI_API_KEY", "test-openai-key")
  782. },
  783. fn: async () => {
  784. const providers = await Provider.list()
  785. expect(Object.keys(providers).length).toBe(0)
  786. },
  787. })
  788. })
  789. test("whitelist and blacklist can be combined", async () => {
  790. await using tmp = await tmpdir({
  791. init: async (dir) => {
  792. await Bun.write(
  793. path.join(dir, "opencode.json"),
  794. JSON.stringify({
  795. $schema: "https://opencode.ai/config.json",
  796. provider: {
  797. anthropic: {
  798. whitelist: ["claude-sonnet-4-20250514", "claude-opus-4-20250514"],
  799. blacklist: ["claude-opus-4-20250514"],
  800. },
  801. },
  802. }),
  803. )
  804. },
  805. })
  806. await Instance.provide({
  807. directory: tmp.path,
  808. init: async () => {
  809. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  810. },
  811. fn: async () => {
  812. const providers = await Provider.list()
  813. expect(providers["anthropic"]).toBeDefined()
  814. const models = Object.keys(providers["anthropic"].models)
  815. expect(models).toContain("claude-sonnet-4-20250514")
  816. expect(models).not.toContain("claude-opus-4-20250514")
  817. expect(models.length).toBe(1)
  818. },
  819. })
  820. })
  821. test("model modalities default correctly", async () => {
  822. await using tmp = await tmpdir({
  823. init: async (dir) => {
  824. await Bun.write(
  825. path.join(dir, "opencode.json"),
  826. JSON.stringify({
  827. $schema: "https://opencode.ai/config.json",
  828. provider: {
  829. "test-provider": {
  830. name: "Test",
  831. npm: "@ai-sdk/openai-compatible",
  832. env: [],
  833. models: {
  834. "test-model": {
  835. name: "Test Model",
  836. tool_call: true,
  837. limit: { context: 8000, output: 2000 },
  838. },
  839. },
  840. options: { apiKey: "test" },
  841. },
  842. },
  843. }),
  844. )
  845. },
  846. })
  847. await Instance.provide({
  848. directory: tmp.path,
  849. fn: async () => {
  850. const providers = await Provider.list()
  851. const model = providers["test-provider"].models["test-model"]
  852. expect(model.capabilities.input.text).toBe(true)
  853. expect(model.capabilities.output.text).toBe(true)
  854. },
  855. })
  856. })
  857. test("model with custom cost values", async () => {
  858. await using tmp = await tmpdir({
  859. init: async (dir) => {
  860. await Bun.write(
  861. path.join(dir, "opencode.json"),
  862. JSON.stringify({
  863. $schema: "https://opencode.ai/config.json",
  864. provider: {
  865. "test-provider": {
  866. name: "Test",
  867. npm: "@ai-sdk/openai-compatible",
  868. env: [],
  869. models: {
  870. "test-model": {
  871. name: "Test Model",
  872. tool_call: true,
  873. limit: { context: 8000, output: 2000 },
  874. cost: {
  875. input: 5,
  876. output: 15,
  877. cache_read: 2.5,
  878. cache_write: 7.5,
  879. },
  880. },
  881. },
  882. options: { apiKey: "test" },
  883. },
  884. },
  885. }),
  886. )
  887. },
  888. })
  889. await Instance.provide({
  890. directory: tmp.path,
  891. fn: async () => {
  892. const providers = await Provider.list()
  893. const model = providers["test-provider"].models["test-model"]
  894. expect(model.cost.input).toBe(5)
  895. expect(model.cost.output).toBe(15)
  896. expect(model.cost.cache.read).toBe(2.5)
  897. expect(model.cost.cache.write).toBe(7.5)
  898. },
  899. })
  900. })
  901. test("getSmallModel returns appropriate small model", async () => {
  902. await using tmp = await tmpdir({
  903. init: async (dir) => {
  904. await Bun.write(
  905. path.join(dir, "opencode.json"),
  906. JSON.stringify({
  907. $schema: "https://opencode.ai/config.json",
  908. }),
  909. )
  910. },
  911. })
  912. await Instance.provide({
  913. directory: tmp.path,
  914. init: async () => {
  915. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  916. },
  917. fn: async () => {
  918. const model = await Provider.getSmallModel("anthropic")
  919. expect(model).toBeDefined()
  920. expect(model?.id).toContain("haiku")
  921. },
  922. })
  923. })
  924. test("getSmallModel respects config small_model override", async () => {
  925. await using tmp = await tmpdir({
  926. init: async (dir) => {
  927. await Bun.write(
  928. path.join(dir, "opencode.json"),
  929. JSON.stringify({
  930. $schema: "https://opencode.ai/config.json",
  931. small_model: "anthropic/claude-sonnet-4-20250514",
  932. }),
  933. )
  934. },
  935. })
  936. await Instance.provide({
  937. directory: tmp.path,
  938. init: async () => {
  939. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  940. },
  941. fn: async () => {
  942. const model = await Provider.getSmallModel("anthropic")
  943. expect(model).toBeDefined()
  944. expect(model?.providerID).toBe("anthropic")
  945. expect(model?.id).toBe("claude-sonnet-4-20250514")
  946. },
  947. })
  948. })
  949. test("provider.sort prioritizes preferred models", () => {
  950. const models = [
  951. { id: "random-model", name: "Random" },
  952. { id: "claude-sonnet-4-latest", name: "Claude Sonnet 4" },
  953. { id: "gpt-5-turbo", name: "GPT-5 Turbo" },
  954. { id: "other-model", name: "Other" },
  955. ] as any[]
  956. const sorted = Provider.sort(models)
  957. expect(sorted[0].id).toContain("sonnet-4")
  958. expect(sorted[0].id).toContain("latest")
  959. expect(sorted[sorted.length - 1].id).not.toContain("gpt-5")
  960. expect(sorted[sorted.length - 1].id).not.toContain("sonnet-4")
  961. })
  962. test("multiple providers can be configured simultaneously", async () => {
  963. await using tmp = await tmpdir({
  964. init: async (dir) => {
  965. await Bun.write(
  966. path.join(dir, "opencode.json"),
  967. JSON.stringify({
  968. $schema: "https://opencode.ai/config.json",
  969. provider: {
  970. anthropic: {
  971. options: { timeout: 30000 },
  972. },
  973. openai: {
  974. options: { timeout: 60000 },
  975. },
  976. },
  977. }),
  978. )
  979. },
  980. })
  981. await Instance.provide({
  982. directory: tmp.path,
  983. init: async () => {
  984. Env.set("ANTHROPIC_API_KEY", "test-anthropic-key")
  985. Env.set("OPENAI_API_KEY", "test-openai-key")
  986. },
  987. fn: async () => {
  988. const providers = await Provider.list()
  989. expect(providers["anthropic"]).toBeDefined()
  990. expect(providers["openai"]).toBeDefined()
  991. expect(providers["anthropic"].options.timeout).toBe(30000)
  992. expect(providers["openai"].options.timeout).toBe(60000)
  993. },
  994. })
  995. })
  996. test("provider with custom npm package", async () => {
  997. await using tmp = await tmpdir({
  998. init: async (dir) => {
  999. await Bun.write(
  1000. path.join(dir, "opencode.json"),
  1001. JSON.stringify({
  1002. $schema: "https://opencode.ai/config.json",
  1003. provider: {
  1004. "local-llm": {
  1005. name: "Local LLM",
  1006. npm: "@ai-sdk/openai-compatible",
  1007. env: [],
  1008. models: {
  1009. "llama-3": {
  1010. name: "Llama 3",
  1011. tool_call: true,
  1012. limit: { context: 8192, output: 2048 },
  1013. },
  1014. },
  1015. options: {
  1016. apiKey: "not-needed",
  1017. baseURL: "http://localhost:11434/v1",
  1018. },
  1019. },
  1020. },
  1021. }),
  1022. )
  1023. },
  1024. })
  1025. await Instance.provide({
  1026. directory: tmp.path,
  1027. fn: async () => {
  1028. const providers = await Provider.list()
  1029. expect(providers["local-llm"]).toBeDefined()
  1030. expect(providers["local-llm"].models["llama-3"].api.npm).toBe("@ai-sdk/openai-compatible")
  1031. expect(providers["local-llm"].options.baseURL).toBe("http://localhost:11434/v1")
  1032. },
  1033. })
  1034. })
  1035. // Edge cases for model configuration
  1036. test("model alias name defaults to alias key when id differs", async () => {
  1037. await using tmp = await tmpdir({
  1038. init: async (dir) => {
  1039. await Bun.write(
  1040. path.join(dir, "opencode.json"),
  1041. JSON.stringify({
  1042. $schema: "https://opencode.ai/config.json",
  1043. provider: {
  1044. anthropic: {
  1045. models: {
  1046. sonnet: {
  1047. id: "claude-sonnet-4-20250514",
  1048. // no name specified - should default to "sonnet" (the key)
  1049. },
  1050. },
  1051. },
  1052. },
  1053. }),
  1054. )
  1055. },
  1056. })
  1057. await Instance.provide({
  1058. directory: tmp.path,
  1059. init: async () => {
  1060. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  1061. },
  1062. fn: async () => {
  1063. const providers = await Provider.list()
  1064. expect(providers["anthropic"].models["sonnet"].name).toBe("sonnet")
  1065. },
  1066. })
  1067. })
  1068. test("provider with multiple env var options only includes apiKey when single env", async () => {
  1069. await using tmp = await tmpdir({
  1070. init: async (dir) => {
  1071. await Bun.write(
  1072. path.join(dir, "opencode.json"),
  1073. JSON.stringify({
  1074. $schema: "https://opencode.ai/config.json",
  1075. provider: {
  1076. "multi-env": {
  1077. name: "Multi Env Provider",
  1078. npm: "@ai-sdk/openai-compatible",
  1079. env: ["MULTI_ENV_KEY_1", "MULTI_ENV_KEY_2"],
  1080. models: {
  1081. "model-1": {
  1082. name: "Model 1",
  1083. tool_call: true,
  1084. limit: { context: 8000, output: 2000 },
  1085. },
  1086. },
  1087. options: {
  1088. baseURL: "https://api.example.com/v1",
  1089. },
  1090. },
  1091. },
  1092. }),
  1093. )
  1094. },
  1095. })
  1096. await Instance.provide({
  1097. directory: tmp.path,
  1098. init: async () => {
  1099. Env.set("MULTI_ENV_KEY_1", "test-key")
  1100. },
  1101. fn: async () => {
  1102. const providers = await Provider.list()
  1103. expect(providers["multi-env"]).toBeDefined()
  1104. // When multiple env options exist, key should NOT be auto-set
  1105. expect(providers["multi-env"].key).toBeUndefined()
  1106. },
  1107. })
  1108. })
  1109. test("provider with single env var includes apiKey automatically", async () => {
  1110. await using tmp = await tmpdir({
  1111. init: async (dir) => {
  1112. await Bun.write(
  1113. path.join(dir, "opencode.json"),
  1114. JSON.stringify({
  1115. $schema: "https://opencode.ai/config.json",
  1116. provider: {
  1117. "single-env": {
  1118. name: "Single Env Provider",
  1119. npm: "@ai-sdk/openai-compatible",
  1120. env: ["SINGLE_ENV_KEY"],
  1121. models: {
  1122. "model-1": {
  1123. name: "Model 1",
  1124. tool_call: true,
  1125. limit: { context: 8000, output: 2000 },
  1126. },
  1127. },
  1128. options: {
  1129. baseURL: "https://api.example.com/v1",
  1130. },
  1131. },
  1132. },
  1133. }),
  1134. )
  1135. },
  1136. })
  1137. await Instance.provide({
  1138. directory: tmp.path,
  1139. init: async () => {
  1140. Env.set("SINGLE_ENV_KEY", "my-api-key")
  1141. },
  1142. fn: async () => {
  1143. const providers = await Provider.list()
  1144. expect(providers["single-env"]).toBeDefined()
  1145. // Single env option should auto-set key
  1146. expect(providers["single-env"].key).toBe("my-api-key")
  1147. },
  1148. })
  1149. })
  1150. test("model cost overrides existing cost values", async () => {
  1151. await using tmp = await tmpdir({
  1152. init: async (dir) => {
  1153. await Bun.write(
  1154. path.join(dir, "opencode.json"),
  1155. JSON.stringify({
  1156. $schema: "https://opencode.ai/config.json",
  1157. provider: {
  1158. anthropic: {
  1159. models: {
  1160. "claude-sonnet-4-20250514": {
  1161. cost: {
  1162. input: 999,
  1163. output: 888,
  1164. },
  1165. },
  1166. },
  1167. },
  1168. },
  1169. }),
  1170. )
  1171. },
  1172. })
  1173. await Instance.provide({
  1174. directory: tmp.path,
  1175. init: async () => {
  1176. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  1177. },
  1178. fn: async () => {
  1179. const providers = await Provider.list()
  1180. const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
  1181. expect(model.cost.input).toBe(999)
  1182. expect(model.cost.output).toBe(888)
  1183. },
  1184. })
  1185. })
  1186. test("completely new provider not in database can be configured", async () => {
  1187. await using tmp = await tmpdir({
  1188. init: async (dir) => {
  1189. await Bun.write(
  1190. path.join(dir, "opencode.json"),
  1191. JSON.stringify({
  1192. $schema: "https://opencode.ai/config.json",
  1193. provider: {
  1194. "brand-new-provider": {
  1195. name: "Brand New",
  1196. npm: "@ai-sdk/openai-compatible",
  1197. env: [],
  1198. api: "https://new-api.com/v1",
  1199. models: {
  1200. "new-model": {
  1201. name: "New Model",
  1202. tool_call: true,
  1203. reasoning: true,
  1204. attachment: true,
  1205. temperature: true,
  1206. limit: { context: 32000, output: 8000 },
  1207. modalities: {
  1208. input: ["text", "image"],
  1209. output: ["text"],
  1210. },
  1211. },
  1212. },
  1213. options: {
  1214. apiKey: "new-key",
  1215. },
  1216. },
  1217. },
  1218. }),
  1219. )
  1220. },
  1221. })
  1222. await Instance.provide({
  1223. directory: tmp.path,
  1224. fn: async () => {
  1225. const providers = await Provider.list()
  1226. expect(providers["brand-new-provider"]).toBeDefined()
  1227. expect(providers["brand-new-provider"].name).toBe("Brand New")
  1228. const model = providers["brand-new-provider"].models["new-model"]
  1229. expect(model.capabilities.reasoning).toBe(true)
  1230. expect(model.capabilities.attachment).toBe(true)
  1231. expect(model.capabilities.input.image).toBe(true)
  1232. },
  1233. })
  1234. })
  1235. test("disabled_providers and enabled_providers interaction", async () => {
  1236. await using tmp = await tmpdir({
  1237. init: async (dir) => {
  1238. await Bun.write(
  1239. path.join(dir, "opencode.json"),
  1240. JSON.stringify({
  1241. $schema: "https://opencode.ai/config.json",
  1242. // enabled_providers takes precedence - only these are considered
  1243. enabled_providers: ["anthropic", "openai"],
  1244. // Then disabled_providers filters from the enabled set
  1245. disabled_providers: ["openai"],
  1246. }),
  1247. )
  1248. },
  1249. })
  1250. await Instance.provide({
  1251. directory: tmp.path,
  1252. init: async () => {
  1253. Env.set("ANTHROPIC_API_KEY", "test-anthropic")
  1254. Env.set("OPENAI_API_KEY", "test-openai")
  1255. Env.set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google")
  1256. },
  1257. fn: async () => {
  1258. const providers = await Provider.list()
  1259. // anthropic: in enabled, not in disabled = allowed
  1260. expect(providers["anthropic"]).toBeDefined()
  1261. // openai: in enabled, but also in disabled = NOT allowed
  1262. expect(providers["openai"]).toBeUndefined()
  1263. // google: not in enabled = NOT allowed (even though not disabled)
  1264. expect(providers["google"]).toBeUndefined()
  1265. },
  1266. })
  1267. })
  1268. test("model with tool_call false", async () => {
  1269. await using tmp = await tmpdir({
  1270. init: async (dir) => {
  1271. await Bun.write(
  1272. path.join(dir, "opencode.json"),
  1273. JSON.stringify({
  1274. $schema: "https://opencode.ai/config.json",
  1275. provider: {
  1276. "no-tools": {
  1277. name: "No Tools Provider",
  1278. npm: "@ai-sdk/openai-compatible",
  1279. env: [],
  1280. models: {
  1281. "basic-model": {
  1282. name: "Basic Model",
  1283. tool_call: false,
  1284. limit: { context: 4000, output: 1000 },
  1285. },
  1286. },
  1287. options: { apiKey: "test" },
  1288. },
  1289. },
  1290. }),
  1291. )
  1292. },
  1293. })
  1294. await Instance.provide({
  1295. directory: tmp.path,
  1296. fn: async () => {
  1297. const providers = await Provider.list()
  1298. expect(providers["no-tools"].models["basic-model"].capabilities.toolcall).toBe(false)
  1299. },
  1300. })
  1301. })
  1302. test("model defaults tool_call to true when not specified", async () => {
  1303. await using tmp = await tmpdir({
  1304. init: async (dir) => {
  1305. await Bun.write(
  1306. path.join(dir, "opencode.json"),
  1307. JSON.stringify({
  1308. $schema: "https://opencode.ai/config.json",
  1309. provider: {
  1310. "default-tools": {
  1311. name: "Default Tools Provider",
  1312. npm: "@ai-sdk/openai-compatible",
  1313. env: [],
  1314. models: {
  1315. model: {
  1316. name: "Model",
  1317. // tool_call not specified
  1318. limit: { context: 4000, output: 1000 },
  1319. },
  1320. },
  1321. options: { apiKey: "test" },
  1322. },
  1323. },
  1324. }),
  1325. )
  1326. },
  1327. })
  1328. await Instance.provide({
  1329. directory: tmp.path,
  1330. fn: async () => {
  1331. const providers = await Provider.list()
  1332. expect(providers["default-tools"].models["model"].capabilities.toolcall).toBe(true)
  1333. },
  1334. })
  1335. })
  1336. test("model headers are preserved", async () => {
  1337. await using tmp = await tmpdir({
  1338. init: async (dir) => {
  1339. await Bun.write(
  1340. path.join(dir, "opencode.json"),
  1341. JSON.stringify({
  1342. $schema: "https://opencode.ai/config.json",
  1343. provider: {
  1344. "headers-provider": {
  1345. name: "Headers Provider",
  1346. npm: "@ai-sdk/openai-compatible",
  1347. env: [],
  1348. models: {
  1349. model: {
  1350. name: "Model",
  1351. tool_call: true,
  1352. limit: { context: 4000, output: 1000 },
  1353. headers: {
  1354. "X-Custom-Header": "custom-value",
  1355. Authorization: "Bearer special-token",
  1356. },
  1357. },
  1358. },
  1359. options: { apiKey: "test" },
  1360. },
  1361. },
  1362. }),
  1363. )
  1364. },
  1365. })
  1366. await Instance.provide({
  1367. directory: tmp.path,
  1368. fn: async () => {
  1369. const providers = await Provider.list()
  1370. const model = providers["headers-provider"].models["model"]
  1371. expect(model.headers).toEqual({
  1372. "X-Custom-Header": "custom-value",
  1373. Authorization: "Bearer special-token",
  1374. })
  1375. },
  1376. })
  1377. })
  1378. test("provider env fallback - second env var used if first missing", async () => {
  1379. await using tmp = await tmpdir({
  1380. init: async (dir) => {
  1381. await Bun.write(
  1382. path.join(dir, "opencode.json"),
  1383. JSON.stringify({
  1384. $schema: "https://opencode.ai/config.json",
  1385. provider: {
  1386. "fallback-env": {
  1387. name: "Fallback Env Provider",
  1388. npm: "@ai-sdk/openai-compatible",
  1389. env: ["PRIMARY_KEY", "FALLBACK_KEY"],
  1390. models: {
  1391. model: {
  1392. name: "Model",
  1393. tool_call: true,
  1394. limit: { context: 4000, output: 1000 },
  1395. },
  1396. },
  1397. options: { baseURL: "https://api.example.com" },
  1398. },
  1399. },
  1400. }),
  1401. )
  1402. },
  1403. })
  1404. await Instance.provide({
  1405. directory: tmp.path,
  1406. init: async () => {
  1407. // Only set fallback, not primary
  1408. Env.set("FALLBACK_KEY", "fallback-api-key")
  1409. },
  1410. fn: async () => {
  1411. const providers = await Provider.list()
  1412. // Provider should load because fallback env var is set
  1413. expect(providers["fallback-env"]).toBeDefined()
  1414. },
  1415. })
  1416. })
  1417. test("getModel returns consistent results", async () => {
  1418. await using tmp = await tmpdir({
  1419. init: async (dir) => {
  1420. await Bun.write(
  1421. path.join(dir, "opencode.json"),
  1422. JSON.stringify({
  1423. $schema: "https://opencode.ai/config.json",
  1424. }),
  1425. )
  1426. },
  1427. })
  1428. await Instance.provide({
  1429. directory: tmp.path,
  1430. init: async () => {
  1431. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  1432. },
  1433. fn: async () => {
  1434. const model1 = await Provider.getModel("anthropic", "claude-sonnet-4-20250514")
  1435. const model2 = await Provider.getModel("anthropic", "claude-sonnet-4-20250514")
  1436. expect(model1.providerID).toEqual(model2.providerID)
  1437. expect(model1.id).toEqual(model2.id)
  1438. expect(model1).toEqual(model2)
  1439. },
  1440. })
  1441. })
  1442. test("provider name defaults to id when not in database", async () => {
  1443. await using tmp = await tmpdir({
  1444. init: async (dir) => {
  1445. await Bun.write(
  1446. path.join(dir, "opencode.json"),
  1447. JSON.stringify({
  1448. $schema: "https://opencode.ai/config.json",
  1449. provider: {
  1450. "my-custom-id": {
  1451. // no name specified
  1452. npm: "@ai-sdk/openai-compatible",
  1453. env: [],
  1454. models: {
  1455. model: {
  1456. name: "Model",
  1457. tool_call: true,
  1458. limit: { context: 4000, output: 1000 },
  1459. },
  1460. },
  1461. options: { apiKey: "test" },
  1462. },
  1463. },
  1464. }),
  1465. )
  1466. },
  1467. })
  1468. await Instance.provide({
  1469. directory: tmp.path,
  1470. fn: async () => {
  1471. const providers = await Provider.list()
  1472. expect(providers["my-custom-id"].name).toBe("my-custom-id")
  1473. },
  1474. })
  1475. })
  1476. test("ModelNotFoundError includes suggestions for typos", async () => {
  1477. await using tmp = await tmpdir({
  1478. init: async (dir) => {
  1479. await Bun.write(
  1480. path.join(dir, "opencode.json"),
  1481. JSON.stringify({
  1482. $schema: "https://opencode.ai/config.json",
  1483. }),
  1484. )
  1485. },
  1486. })
  1487. await Instance.provide({
  1488. directory: tmp.path,
  1489. init: async () => {
  1490. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  1491. },
  1492. fn: async () => {
  1493. try {
  1494. await Provider.getModel("anthropic", "claude-sonet-4") // typo: sonet instead of sonnet
  1495. expect(true).toBe(false) // Should not reach here
  1496. } catch (e: any) {
  1497. expect(e.data.suggestions).toBeDefined()
  1498. expect(e.data.suggestions.length).toBeGreaterThan(0)
  1499. }
  1500. },
  1501. })
  1502. })
  1503. test("ModelNotFoundError for provider includes suggestions", async () => {
  1504. await using tmp = await tmpdir({
  1505. init: async (dir) => {
  1506. await Bun.write(
  1507. path.join(dir, "opencode.json"),
  1508. JSON.stringify({
  1509. $schema: "https://opencode.ai/config.json",
  1510. }),
  1511. )
  1512. },
  1513. })
  1514. await Instance.provide({
  1515. directory: tmp.path,
  1516. init: async () => {
  1517. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  1518. },
  1519. fn: async () => {
  1520. try {
  1521. await Provider.getModel("antropic", "claude-sonnet-4") // typo: antropic
  1522. expect(true).toBe(false) // Should not reach here
  1523. } catch (e: any) {
  1524. expect(e.data.suggestions).toBeDefined()
  1525. expect(e.data.suggestions).toContain("anthropic")
  1526. }
  1527. },
  1528. })
  1529. })
  1530. test("getProvider returns undefined for nonexistent provider", async () => {
  1531. await using tmp = await tmpdir({
  1532. init: async (dir) => {
  1533. await Bun.write(
  1534. path.join(dir, "opencode.json"),
  1535. JSON.stringify({
  1536. $schema: "https://opencode.ai/config.json",
  1537. }),
  1538. )
  1539. },
  1540. })
  1541. await Instance.provide({
  1542. directory: tmp.path,
  1543. fn: async () => {
  1544. const provider = await Provider.getProvider("nonexistent")
  1545. expect(provider).toBeUndefined()
  1546. },
  1547. })
  1548. })
  1549. test("getProvider returns provider info", async () => {
  1550. await using tmp = await tmpdir({
  1551. init: async (dir) => {
  1552. await Bun.write(
  1553. path.join(dir, "opencode.json"),
  1554. JSON.stringify({
  1555. $schema: "https://opencode.ai/config.json",
  1556. }),
  1557. )
  1558. },
  1559. })
  1560. await Instance.provide({
  1561. directory: tmp.path,
  1562. init: async () => {
  1563. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  1564. },
  1565. fn: async () => {
  1566. const provider = await Provider.getProvider("anthropic")
  1567. expect(provider).toBeDefined()
  1568. expect(provider?.id).toBe("anthropic")
  1569. },
  1570. })
  1571. })
  1572. test("closest returns undefined when no partial match found", async () => {
  1573. await using tmp = await tmpdir({
  1574. init: async (dir) => {
  1575. await Bun.write(
  1576. path.join(dir, "opencode.json"),
  1577. JSON.stringify({
  1578. $schema: "https://opencode.ai/config.json",
  1579. }),
  1580. )
  1581. },
  1582. })
  1583. await Instance.provide({
  1584. directory: tmp.path,
  1585. init: async () => {
  1586. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  1587. },
  1588. fn: async () => {
  1589. const result = await Provider.closest("anthropic", ["nonexistent-xyz-model"])
  1590. expect(result).toBeUndefined()
  1591. },
  1592. })
  1593. })
  1594. test("closest checks multiple query terms in order", async () => {
  1595. await using tmp = await tmpdir({
  1596. init: async (dir) => {
  1597. await Bun.write(
  1598. path.join(dir, "opencode.json"),
  1599. JSON.stringify({
  1600. $schema: "https://opencode.ai/config.json",
  1601. }),
  1602. )
  1603. },
  1604. })
  1605. await Instance.provide({
  1606. directory: tmp.path,
  1607. init: async () => {
  1608. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  1609. },
  1610. fn: async () => {
  1611. // First term won't match, second will
  1612. const result = await Provider.closest("anthropic", ["nonexistent", "haiku"])
  1613. expect(result).toBeDefined()
  1614. expect(result?.modelID).toContain("haiku")
  1615. },
  1616. })
  1617. })
  1618. test("model limit defaults to zero when not specified", async () => {
  1619. await using tmp = await tmpdir({
  1620. init: async (dir) => {
  1621. await Bun.write(
  1622. path.join(dir, "opencode.json"),
  1623. JSON.stringify({
  1624. $schema: "https://opencode.ai/config.json",
  1625. provider: {
  1626. "no-limit": {
  1627. name: "No Limit Provider",
  1628. npm: "@ai-sdk/openai-compatible",
  1629. env: [],
  1630. models: {
  1631. model: {
  1632. name: "Model",
  1633. tool_call: true,
  1634. // no limit specified
  1635. },
  1636. },
  1637. options: { apiKey: "test" },
  1638. },
  1639. },
  1640. }),
  1641. )
  1642. },
  1643. })
  1644. await Instance.provide({
  1645. directory: tmp.path,
  1646. fn: async () => {
  1647. const providers = await Provider.list()
  1648. const model = providers["no-limit"].models["model"]
  1649. expect(model.limit.context).toBe(0)
  1650. expect(model.limit.output).toBe(0)
  1651. },
  1652. })
  1653. })
  1654. test("provider options are deeply merged", async () => {
  1655. await using tmp = await tmpdir({
  1656. init: async (dir) => {
  1657. await Bun.write(
  1658. path.join(dir, "opencode.json"),
  1659. JSON.stringify({
  1660. $schema: "https://opencode.ai/config.json",
  1661. provider: {
  1662. anthropic: {
  1663. options: {
  1664. headers: {
  1665. "X-Custom": "custom-value",
  1666. },
  1667. timeout: 30000,
  1668. },
  1669. },
  1670. },
  1671. }),
  1672. )
  1673. },
  1674. })
  1675. await Instance.provide({
  1676. directory: tmp.path,
  1677. init: async () => {
  1678. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  1679. },
  1680. fn: async () => {
  1681. const providers = await Provider.list()
  1682. // Custom options should be merged
  1683. expect(providers["anthropic"].options.timeout).toBe(30000)
  1684. expect(providers["anthropic"].options.headers["X-Custom"]).toBe("custom-value")
  1685. // anthropic custom loader adds its own headers, they should coexist
  1686. expect(providers["anthropic"].options.headers["anthropic-beta"]).toBeDefined()
  1687. },
  1688. })
  1689. })
  1690. test("custom model inherits npm package from models.dev provider config", async () => {
  1691. await using tmp = await tmpdir({
  1692. init: async (dir) => {
  1693. await Bun.write(
  1694. path.join(dir, "opencode.json"),
  1695. JSON.stringify({
  1696. $schema: "https://opencode.ai/config.json",
  1697. provider: {
  1698. openai: {
  1699. models: {
  1700. "my-custom-model": {
  1701. name: "My Custom Model",
  1702. tool_call: true,
  1703. limit: { context: 8000, output: 2000 },
  1704. },
  1705. },
  1706. },
  1707. },
  1708. }),
  1709. )
  1710. },
  1711. })
  1712. await Instance.provide({
  1713. directory: tmp.path,
  1714. init: async () => {
  1715. Env.set("OPENAI_API_KEY", "test-api-key")
  1716. },
  1717. fn: async () => {
  1718. const providers = await Provider.list()
  1719. const model = providers["openai"].models["my-custom-model"]
  1720. expect(model).toBeDefined()
  1721. expect(model.api.npm).toBe("@ai-sdk/openai")
  1722. },
  1723. })
  1724. })
  1725. test("custom model inherits api.url from models.dev provider", async () => {
  1726. await using tmp = await tmpdir({
  1727. init: async (dir) => {
  1728. await Bun.write(
  1729. path.join(dir, "opencode.json"),
  1730. JSON.stringify({
  1731. $schema: "https://opencode.ai/config.json",
  1732. provider: {
  1733. openrouter: {
  1734. models: {
  1735. "prime-intellect/intellect-3": {},
  1736. "deepseek/deepseek-r1-0528": {
  1737. name: "DeepSeek R1",
  1738. },
  1739. },
  1740. },
  1741. },
  1742. }),
  1743. )
  1744. },
  1745. })
  1746. await Instance.provide({
  1747. directory: tmp.path,
  1748. init: async () => {
  1749. Env.set("OPENROUTER_API_KEY", "test-api-key")
  1750. },
  1751. fn: async () => {
  1752. const providers = await Provider.list()
  1753. expect(providers["openrouter"]).toBeDefined()
  1754. // New model not in database should inherit api.url from provider
  1755. const intellect = providers["openrouter"].models["prime-intellect/intellect-3"]
  1756. expect(intellect).toBeDefined()
  1757. expect(intellect.api.url).toBe("https://openrouter.ai/api/v1")
  1758. // Another new model should also inherit api.url
  1759. const deepseek = providers["openrouter"].models["deepseek/deepseek-r1-0528"]
  1760. expect(deepseek).toBeDefined()
  1761. expect(deepseek.api.url).toBe("https://openrouter.ai/api/v1")
  1762. expect(deepseek.name).toBe("DeepSeek R1")
  1763. },
  1764. })
  1765. })
  1766. test("model variants are generated for reasoning models", async () => {
  1767. await using tmp = await tmpdir({
  1768. init: async (dir) => {
  1769. await Bun.write(
  1770. path.join(dir, "opencode.json"),
  1771. JSON.stringify({
  1772. $schema: "https://opencode.ai/config.json",
  1773. }),
  1774. )
  1775. },
  1776. })
  1777. await Instance.provide({
  1778. directory: tmp.path,
  1779. init: async () => {
  1780. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  1781. },
  1782. fn: async () => {
  1783. const providers = await Provider.list()
  1784. // Claude sonnet 4 has reasoning capability
  1785. const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
  1786. expect(model.capabilities.reasoning).toBe(true)
  1787. expect(model.variants).toBeDefined()
  1788. expect(Object.keys(model.variants!).length).toBeGreaterThan(0)
  1789. },
  1790. })
  1791. })
  1792. test("model variants can be disabled via config", async () => {
  1793. await using tmp = await tmpdir({
  1794. init: async (dir) => {
  1795. await Bun.write(
  1796. path.join(dir, "opencode.json"),
  1797. JSON.stringify({
  1798. $schema: "https://opencode.ai/config.json",
  1799. provider: {
  1800. anthropic: {
  1801. models: {
  1802. "claude-sonnet-4-20250514": {
  1803. variants: {
  1804. high: { disabled: true },
  1805. },
  1806. },
  1807. },
  1808. },
  1809. },
  1810. }),
  1811. )
  1812. },
  1813. })
  1814. await Instance.provide({
  1815. directory: tmp.path,
  1816. init: async () => {
  1817. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  1818. },
  1819. fn: async () => {
  1820. const providers = await Provider.list()
  1821. const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
  1822. expect(model.variants).toBeDefined()
  1823. expect(model.variants!["high"]).toBeUndefined()
  1824. // max variant should still exist
  1825. expect(model.variants!["max"]).toBeDefined()
  1826. },
  1827. })
  1828. })
  1829. test("model variants can be customized via config", async () => {
  1830. await using tmp = await tmpdir({
  1831. init: async (dir) => {
  1832. await Bun.write(
  1833. path.join(dir, "opencode.json"),
  1834. JSON.stringify({
  1835. $schema: "https://opencode.ai/config.json",
  1836. provider: {
  1837. anthropic: {
  1838. models: {
  1839. "claude-sonnet-4-20250514": {
  1840. variants: {
  1841. high: {
  1842. thinking: {
  1843. type: "enabled",
  1844. budgetTokens: 20000,
  1845. },
  1846. },
  1847. },
  1848. },
  1849. },
  1850. },
  1851. },
  1852. }),
  1853. )
  1854. },
  1855. })
  1856. await Instance.provide({
  1857. directory: tmp.path,
  1858. init: async () => {
  1859. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  1860. },
  1861. fn: async () => {
  1862. const providers = await Provider.list()
  1863. const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
  1864. expect(model.variants!["high"]).toBeDefined()
  1865. expect(model.variants!["high"].thinking.budgetTokens).toBe(20000)
  1866. },
  1867. })
  1868. })
  1869. test("disabled key is stripped from variant config", async () => {
  1870. await using tmp = await tmpdir({
  1871. init: async (dir) => {
  1872. await Bun.write(
  1873. path.join(dir, "opencode.json"),
  1874. JSON.stringify({
  1875. $schema: "https://opencode.ai/config.json",
  1876. provider: {
  1877. anthropic: {
  1878. models: {
  1879. "claude-sonnet-4-20250514": {
  1880. variants: {
  1881. max: {
  1882. disabled: false,
  1883. customField: "test",
  1884. },
  1885. },
  1886. },
  1887. },
  1888. },
  1889. },
  1890. }),
  1891. )
  1892. },
  1893. })
  1894. await Instance.provide({
  1895. directory: tmp.path,
  1896. init: async () => {
  1897. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  1898. },
  1899. fn: async () => {
  1900. const providers = await Provider.list()
  1901. const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
  1902. expect(model.variants!["max"]).toBeDefined()
  1903. expect(model.variants!["max"].disabled).toBeUndefined()
  1904. expect(model.variants!["max"].customField).toBe("test")
  1905. },
  1906. })
  1907. })
  1908. test("all variants can be disabled via config", async () => {
  1909. await using tmp = await tmpdir({
  1910. init: async (dir) => {
  1911. await Bun.write(
  1912. path.join(dir, "opencode.json"),
  1913. JSON.stringify({
  1914. $schema: "https://opencode.ai/config.json",
  1915. provider: {
  1916. anthropic: {
  1917. models: {
  1918. "claude-sonnet-4-20250514": {
  1919. variants: {
  1920. high: { disabled: true },
  1921. max: { disabled: true },
  1922. },
  1923. },
  1924. },
  1925. },
  1926. },
  1927. }),
  1928. )
  1929. },
  1930. })
  1931. await Instance.provide({
  1932. directory: tmp.path,
  1933. init: async () => {
  1934. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  1935. },
  1936. fn: async () => {
  1937. const providers = await Provider.list()
  1938. const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
  1939. expect(model.variants).toBeDefined()
  1940. expect(Object.keys(model.variants!).length).toBe(0)
  1941. },
  1942. })
  1943. })
  1944. test("variant config merges with generated variants", async () => {
  1945. await using tmp = await tmpdir({
  1946. init: async (dir) => {
  1947. await Bun.write(
  1948. path.join(dir, "opencode.json"),
  1949. JSON.stringify({
  1950. $schema: "https://opencode.ai/config.json",
  1951. provider: {
  1952. anthropic: {
  1953. models: {
  1954. "claude-sonnet-4-20250514": {
  1955. variants: {
  1956. high: {
  1957. extraOption: "custom-value",
  1958. },
  1959. },
  1960. },
  1961. },
  1962. },
  1963. },
  1964. }),
  1965. )
  1966. },
  1967. })
  1968. await Instance.provide({
  1969. directory: tmp.path,
  1970. init: async () => {
  1971. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  1972. },
  1973. fn: async () => {
  1974. const providers = await Provider.list()
  1975. const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
  1976. expect(model.variants!["high"]).toBeDefined()
  1977. // Should have both the generated thinking config and the custom option
  1978. expect(model.variants!["high"].thinking).toBeDefined()
  1979. expect(model.variants!["high"].extraOption).toBe("custom-value")
  1980. },
  1981. })
  1982. })
  1983. test("variants filtered in second pass for database models", async () => {
  1984. await using tmp = await tmpdir({
  1985. init: async (dir) => {
  1986. await Bun.write(
  1987. path.join(dir, "opencode.json"),
  1988. JSON.stringify({
  1989. $schema: "https://opencode.ai/config.json",
  1990. provider: {
  1991. openai: {
  1992. models: {
  1993. "gpt-5": {
  1994. variants: {
  1995. high: { disabled: true },
  1996. },
  1997. },
  1998. },
  1999. },
  2000. },
  2001. }),
  2002. )
  2003. },
  2004. })
  2005. await Instance.provide({
  2006. directory: tmp.path,
  2007. init: async () => {
  2008. Env.set("OPENAI_API_KEY", "test-api-key")
  2009. },
  2010. fn: async () => {
  2011. const providers = await Provider.list()
  2012. const model = providers["openai"].models["gpt-5"]
  2013. expect(model.variants).toBeDefined()
  2014. expect(model.variants!["high"]).toBeUndefined()
  2015. // Other variants should still exist
  2016. expect(model.variants!["medium"]).toBeDefined()
  2017. },
  2018. })
  2019. })
  2020. test("custom model with variants enabled and disabled", async () => {
  2021. await using tmp = await tmpdir({
  2022. init: async (dir) => {
  2023. await Bun.write(
  2024. path.join(dir, "opencode.json"),
  2025. JSON.stringify({
  2026. $schema: "https://opencode.ai/config.json",
  2027. provider: {
  2028. "custom-reasoning": {
  2029. name: "Custom Reasoning Provider",
  2030. npm: "@ai-sdk/openai-compatible",
  2031. env: [],
  2032. models: {
  2033. "reasoning-model": {
  2034. name: "Reasoning Model",
  2035. tool_call: true,
  2036. reasoning: true,
  2037. limit: { context: 128000, output: 16000 },
  2038. variants: {
  2039. low: { reasoningEffort: "low" },
  2040. medium: { reasoningEffort: "medium" },
  2041. high: { reasoningEffort: "high", disabled: true },
  2042. custom: { reasoningEffort: "custom", budgetTokens: 5000 },
  2043. },
  2044. },
  2045. },
  2046. options: { apiKey: "test-key" },
  2047. },
  2048. },
  2049. }),
  2050. )
  2051. },
  2052. })
  2053. await Instance.provide({
  2054. directory: tmp.path,
  2055. fn: async () => {
  2056. const providers = await Provider.list()
  2057. const model = providers["custom-reasoning"].models["reasoning-model"]
  2058. expect(model.variants).toBeDefined()
  2059. // Enabled variants should exist
  2060. expect(model.variants!["low"]).toBeDefined()
  2061. expect(model.variants!["low"].reasoningEffort).toBe("low")
  2062. expect(model.variants!["medium"]).toBeDefined()
  2063. expect(model.variants!["medium"].reasoningEffort).toBe("medium")
  2064. expect(model.variants!["custom"]).toBeDefined()
  2065. expect(model.variants!["custom"].reasoningEffort).toBe("custom")
  2066. expect(model.variants!["custom"].budgetTokens).toBe(5000)
  2067. // Disabled variant should not exist
  2068. expect(model.variants!["high"]).toBeUndefined()
  2069. // disabled key should be stripped from all variants
  2070. expect(model.variants!["low"].disabled).toBeUndefined()
  2071. expect(model.variants!["medium"].disabled).toBeUndefined()
  2072. expect(model.variants!["custom"].disabled).toBeUndefined()
  2073. },
  2074. })
  2075. })