provider.test.ts 48 KB

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