provider.test.ts 60 KB

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