provider.test.ts 50 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809
  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"].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"].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"].models["my-alias"]).toBeDefined()
  195. expect(providers["anthropic"].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"].name).toBe("Custom Provider")
  237. expect(providers["custom-provider"].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.id).toBe("claude-sonnet-4-20250514")
  293. const language = await Provider.getLanguage(model)
  294. expect(language).toBeDefined()
  295. },
  296. })
  297. })
  298. test("getModel throws ModelNotFoundError for invalid model", async () => {
  299. await using tmp = await tmpdir({
  300. init: async (dir) => {
  301. await Bun.write(
  302. path.join(dir, "opencode.json"),
  303. JSON.stringify({
  304. $schema: "https://opencode.ai/config.json",
  305. }),
  306. )
  307. },
  308. })
  309. await Instance.provide({
  310. directory: tmp.path,
  311. init: async () => {
  312. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  313. },
  314. fn: async () => {
  315. expect(Provider.getModel("anthropic", "nonexistent-model")).rejects.toThrow()
  316. },
  317. })
  318. })
  319. test("getModel throws ModelNotFoundError for invalid provider", async () => {
  320. await using tmp = await tmpdir({
  321. init: async (dir) => {
  322. await Bun.write(
  323. path.join(dir, "opencode.json"),
  324. JSON.stringify({
  325. $schema: "https://opencode.ai/config.json",
  326. }),
  327. )
  328. },
  329. })
  330. await Instance.provide({
  331. directory: tmp.path,
  332. fn: async () => {
  333. expect(Provider.getModel("nonexistent-provider", "some-model")).rejects.toThrow()
  334. },
  335. })
  336. })
  337. test("parseModel correctly parses provider/model string", () => {
  338. const result = Provider.parseModel("anthropic/claude-sonnet-4")
  339. expect(result.providerID).toBe("anthropic")
  340. expect(result.modelID).toBe("claude-sonnet-4")
  341. })
  342. test("parseModel handles model IDs with slashes", () => {
  343. const result = Provider.parseModel("openrouter/anthropic/claude-3-opus")
  344. expect(result.providerID).toBe("openrouter")
  345. expect(result.modelID).toBe("anthropic/claude-3-opus")
  346. })
  347. test("defaultModel returns first available model when no config set", async () => {
  348. await using tmp = await tmpdir({
  349. init: async (dir) => {
  350. await Bun.write(
  351. path.join(dir, "opencode.json"),
  352. JSON.stringify({
  353. $schema: "https://opencode.ai/config.json",
  354. }),
  355. )
  356. },
  357. })
  358. await Instance.provide({
  359. directory: tmp.path,
  360. init: async () => {
  361. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  362. },
  363. fn: async () => {
  364. const model = await Provider.defaultModel()
  365. expect(model.providerID).toBeDefined()
  366. expect(model.modelID).toBeDefined()
  367. },
  368. })
  369. })
  370. test("defaultModel respects config model setting", async () => {
  371. await using tmp = await tmpdir({
  372. init: async (dir) => {
  373. await Bun.write(
  374. path.join(dir, "opencode.json"),
  375. JSON.stringify({
  376. $schema: "https://opencode.ai/config.json",
  377. model: "anthropic/claude-sonnet-4-20250514",
  378. }),
  379. )
  380. },
  381. })
  382. await Instance.provide({
  383. directory: tmp.path,
  384. init: async () => {
  385. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  386. },
  387. fn: async () => {
  388. const model = await Provider.defaultModel()
  389. expect(model.providerID).toBe("anthropic")
  390. expect(model.modelID).toBe("claude-sonnet-4-20250514")
  391. },
  392. })
  393. })
  394. test("provider with baseURL from config", async () => {
  395. await using tmp = await tmpdir({
  396. init: async (dir) => {
  397. await Bun.write(
  398. path.join(dir, "opencode.json"),
  399. JSON.stringify({
  400. $schema: "https://opencode.ai/config.json",
  401. provider: {
  402. "custom-openai": {
  403. name: "Custom OpenAI",
  404. npm: "@ai-sdk/openai-compatible",
  405. env: [],
  406. models: {
  407. "gpt-4": {
  408. name: "GPT-4",
  409. tool_call: true,
  410. limit: { context: 128000, output: 4096 },
  411. },
  412. },
  413. options: {
  414. apiKey: "test-key",
  415. baseURL: "https://custom.openai.com/v1",
  416. },
  417. },
  418. },
  419. }),
  420. )
  421. },
  422. })
  423. await Instance.provide({
  424. directory: tmp.path,
  425. fn: async () => {
  426. const providers = await Provider.list()
  427. expect(providers["custom-openai"]).toBeDefined()
  428. expect(providers["custom-openai"].options.baseURL).toBe("https://custom.openai.com/v1")
  429. },
  430. })
  431. })
  432. test("model cost defaults to zero when not specified", async () => {
  433. await using tmp = await tmpdir({
  434. init: async (dir) => {
  435. await Bun.write(
  436. path.join(dir, "opencode.json"),
  437. JSON.stringify({
  438. $schema: "https://opencode.ai/config.json",
  439. provider: {
  440. "test-provider": {
  441. name: "Test Provider",
  442. npm: "@ai-sdk/openai-compatible",
  443. env: [],
  444. models: {
  445. "test-model": {
  446. name: "Test Model",
  447. tool_call: true,
  448. limit: { context: 128000, output: 4096 },
  449. },
  450. },
  451. options: {
  452. apiKey: "test-key",
  453. },
  454. },
  455. },
  456. }),
  457. )
  458. },
  459. })
  460. await Instance.provide({
  461. directory: tmp.path,
  462. fn: async () => {
  463. const providers = await Provider.list()
  464. const model = providers["test-provider"].models["test-model"]
  465. expect(model.cost.input).toBe(0)
  466. expect(model.cost.output).toBe(0)
  467. expect(model.cost.cache.read).toBe(0)
  468. expect(model.cost.cache.write).toBe(0)
  469. },
  470. })
  471. })
  472. test("model options are merged from existing model", async () => {
  473. await using tmp = await tmpdir({
  474. init: async (dir) => {
  475. await Bun.write(
  476. path.join(dir, "opencode.json"),
  477. JSON.stringify({
  478. $schema: "https://opencode.ai/config.json",
  479. provider: {
  480. anthropic: {
  481. models: {
  482. "claude-sonnet-4-20250514": {
  483. options: {
  484. customOption: "custom-value",
  485. },
  486. },
  487. },
  488. },
  489. },
  490. }),
  491. )
  492. },
  493. })
  494. await Instance.provide({
  495. directory: tmp.path,
  496. init: async () => {
  497. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  498. },
  499. fn: async () => {
  500. const providers = await Provider.list()
  501. const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
  502. expect(model.options.customOption).toBe("custom-value")
  503. },
  504. })
  505. })
  506. test("provider removed when all models filtered out", async () => {
  507. await using tmp = await tmpdir({
  508. init: async (dir) => {
  509. await Bun.write(
  510. path.join(dir, "opencode.json"),
  511. JSON.stringify({
  512. $schema: "https://opencode.ai/config.json",
  513. provider: {
  514. anthropic: {
  515. whitelist: ["nonexistent-model"],
  516. },
  517. },
  518. }),
  519. )
  520. },
  521. })
  522. await Instance.provide({
  523. directory: tmp.path,
  524. init: async () => {
  525. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  526. },
  527. fn: async () => {
  528. const providers = await Provider.list()
  529. expect(providers["anthropic"]).toBeUndefined()
  530. },
  531. })
  532. })
  533. test("closest finds model by partial match", async () => {
  534. await using tmp = await tmpdir({
  535. init: async (dir) => {
  536. await Bun.write(
  537. path.join(dir, "opencode.json"),
  538. JSON.stringify({
  539. $schema: "https://opencode.ai/config.json",
  540. }),
  541. )
  542. },
  543. })
  544. await Instance.provide({
  545. directory: tmp.path,
  546. init: async () => {
  547. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  548. },
  549. fn: async () => {
  550. const result = await Provider.closest("anthropic", ["sonnet-4"])
  551. expect(result).toBeDefined()
  552. expect(result?.providerID).toBe("anthropic")
  553. expect(result?.modelID).toContain("sonnet-4")
  554. },
  555. })
  556. })
  557. test("closest returns undefined for nonexistent provider", async () => {
  558. await using tmp = await tmpdir({
  559. init: async (dir) => {
  560. await Bun.write(
  561. path.join(dir, "opencode.json"),
  562. JSON.stringify({
  563. $schema: "https://opencode.ai/config.json",
  564. }),
  565. )
  566. },
  567. })
  568. await Instance.provide({
  569. directory: tmp.path,
  570. fn: async () => {
  571. const result = await Provider.closest("nonexistent", ["model"])
  572. expect(result).toBeUndefined()
  573. },
  574. })
  575. })
  576. test("getModel uses realIdByKey for aliased models", async () => {
  577. await using tmp = await tmpdir({
  578. init: async (dir) => {
  579. await Bun.write(
  580. path.join(dir, "opencode.json"),
  581. JSON.stringify({
  582. $schema: "https://opencode.ai/config.json",
  583. provider: {
  584. anthropic: {
  585. models: {
  586. "my-sonnet": {
  587. id: "claude-sonnet-4-20250514",
  588. name: "My Sonnet Alias",
  589. },
  590. },
  591. },
  592. },
  593. }),
  594. )
  595. },
  596. })
  597. await Instance.provide({
  598. directory: tmp.path,
  599. init: async () => {
  600. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  601. },
  602. fn: async () => {
  603. const providers = await Provider.list()
  604. expect(providers["anthropic"].models["my-sonnet"]).toBeDefined()
  605. const model = await Provider.getModel("anthropic", "my-sonnet")
  606. expect(model).toBeDefined()
  607. expect(model.id).toBe("my-sonnet")
  608. expect(model.name).toBe("My Sonnet Alias")
  609. },
  610. })
  611. })
  612. test("provider api field sets model api.url", async () => {
  613. await using tmp = await tmpdir({
  614. init: async (dir) => {
  615. await Bun.write(
  616. path.join(dir, "opencode.json"),
  617. JSON.stringify({
  618. $schema: "https://opencode.ai/config.json",
  619. provider: {
  620. "custom-api": {
  621. name: "Custom API",
  622. npm: "@ai-sdk/openai-compatible",
  623. api: "https://api.example.com/v1",
  624. env: [],
  625. models: {
  626. "model-1": {
  627. name: "Model 1",
  628. tool_call: true,
  629. limit: { context: 8000, output: 2000 },
  630. },
  631. },
  632. options: {
  633. apiKey: "test-key",
  634. },
  635. },
  636. },
  637. }),
  638. )
  639. },
  640. })
  641. await Instance.provide({
  642. directory: tmp.path,
  643. fn: async () => {
  644. const providers = await Provider.list()
  645. // api field is stored on model.api.url, used by getSDK to set baseURL
  646. expect(providers["custom-api"].models["model-1"].api.url).toBe("https://api.example.com/v1")
  647. },
  648. })
  649. })
  650. test("explicit baseURL overrides api field", async () => {
  651. await using tmp = await tmpdir({
  652. init: async (dir) => {
  653. await Bun.write(
  654. path.join(dir, "opencode.json"),
  655. JSON.stringify({
  656. $schema: "https://opencode.ai/config.json",
  657. provider: {
  658. "custom-api": {
  659. name: "Custom API",
  660. npm: "@ai-sdk/openai-compatible",
  661. api: "https://api.example.com/v1",
  662. env: [],
  663. models: {
  664. "model-1": {
  665. name: "Model 1",
  666. tool_call: true,
  667. limit: { context: 8000, output: 2000 },
  668. },
  669. },
  670. options: {
  671. apiKey: "test-key",
  672. baseURL: "https://custom.override.com/v1",
  673. },
  674. },
  675. },
  676. }),
  677. )
  678. },
  679. })
  680. await Instance.provide({
  681. directory: tmp.path,
  682. fn: async () => {
  683. const providers = await Provider.list()
  684. expect(providers["custom-api"].options.baseURL).toBe("https://custom.override.com/v1")
  685. },
  686. })
  687. })
  688. test("model inherits properties from existing database model", async () => {
  689. await using tmp = await tmpdir({
  690. init: async (dir) => {
  691. await Bun.write(
  692. path.join(dir, "opencode.json"),
  693. JSON.stringify({
  694. $schema: "https://opencode.ai/config.json",
  695. provider: {
  696. anthropic: {
  697. models: {
  698. "claude-sonnet-4-20250514": {
  699. name: "Custom Name for Sonnet",
  700. },
  701. },
  702. },
  703. },
  704. }),
  705. )
  706. },
  707. })
  708. await Instance.provide({
  709. directory: tmp.path,
  710. init: async () => {
  711. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  712. },
  713. fn: async () => {
  714. const providers = await Provider.list()
  715. const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
  716. expect(model.name).toBe("Custom Name for Sonnet")
  717. expect(model.capabilities.toolcall).toBe(true)
  718. expect(model.capabilities.attachment).toBe(true)
  719. expect(model.limit.context).toBeGreaterThan(0)
  720. },
  721. })
  722. })
  723. test("disabled_providers prevents loading even with env var", async () => {
  724. await using tmp = await tmpdir({
  725. init: async (dir) => {
  726. await Bun.write(
  727. path.join(dir, "opencode.json"),
  728. JSON.stringify({
  729. $schema: "https://opencode.ai/config.json",
  730. disabled_providers: ["openai"],
  731. }),
  732. )
  733. },
  734. })
  735. await Instance.provide({
  736. directory: tmp.path,
  737. init: async () => {
  738. Env.set("OPENAI_API_KEY", "test-openai-key")
  739. },
  740. fn: async () => {
  741. const providers = await Provider.list()
  742. expect(providers["openai"]).toBeUndefined()
  743. },
  744. })
  745. })
  746. test("enabled_providers with empty array allows no providers", async () => {
  747. await using tmp = await tmpdir({
  748. init: async (dir) => {
  749. await Bun.write(
  750. path.join(dir, "opencode.json"),
  751. JSON.stringify({
  752. $schema: "https://opencode.ai/config.json",
  753. enabled_providers: [],
  754. }),
  755. )
  756. },
  757. })
  758. await Instance.provide({
  759. directory: tmp.path,
  760. init: async () => {
  761. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  762. Env.set("OPENAI_API_KEY", "test-openai-key")
  763. },
  764. fn: async () => {
  765. const providers = await Provider.list()
  766. expect(Object.keys(providers).length).toBe(0)
  767. },
  768. })
  769. })
  770. test("whitelist and blacklist can be combined", async () => {
  771. await using tmp = await tmpdir({
  772. init: async (dir) => {
  773. await Bun.write(
  774. path.join(dir, "opencode.json"),
  775. JSON.stringify({
  776. $schema: "https://opencode.ai/config.json",
  777. provider: {
  778. anthropic: {
  779. whitelist: ["claude-sonnet-4-20250514", "claude-opus-4-20250514"],
  780. blacklist: ["claude-opus-4-20250514"],
  781. },
  782. },
  783. }),
  784. )
  785. },
  786. })
  787. await Instance.provide({
  788. directory: tmp.path,
  789. init: async () => {
  790. Env.set("ANTHROPIC_API_KEY", "test-api-key")
  791. },
  792. fn: async () => {
  793. const providers = await Provider.list()
  794. expect(providers["anthropic"]).toBeDefined()
  795. const models = Object.keys(providers["anthropic"].models)
  796. expect(models).toContain("claude-sonnet-4-20250514")
  797. expect(models).not.toContain("claude-opus-4-20250514")
  798. expect(models.length).toBe(1)
  799. },
  800. })
  801. })
  802. test("model modalities default correctly", async () => {
  803. await using tmp = await tmpdir({
  804. init: async (dir) => {
  805. await Bun.write(
  806. path.join(dir, "opencode.json"),
  807. JSON.stringify({
  808. $schema: "https://opencode.ai/config.json",
  809. provider: {
  810. "test-provider": {
  811. name: "Test",
  812. npm: "@ai-sdk/openai-compatible",
  813. env: [],
  814. models: {
  815. "test-model": {
  816. name: "Test Model",
  817. tool_call: true,
  818. limit: { context: 8000, output: 2000 },
  819. },
  820. },
  821. options: { apiKey: "test" },
  822. },
  823. },
  824. }),
  825. )
  826. },
  827. })
  828. await Instance.provide({
  829. directory: tmp.path,
  830. fn: async () => {
  831. const providers = await Provider.list()
  832. const model = providers["test-provider"].models["test-model"]
  833. expect(model.capabilities.input.text).toBe(true)
  834. expect(model.capabilities.output.text).toBe(true)
  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"].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?.id).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?.id).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"].models["llama-3"].api.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"].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, key should NOT be auto-set
  1086. expect(providers["multi-env"].key).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 key
  1127. expect(providers["single-env"].key).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"].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"].name).toBe("Brand New")
  1209. const model = providers["brand-new-provider"].models["new-model"]
  1210. expect(model.capabilities.reasoning).toBe(true)
  1211. expect(model.capabilities.attachment).toBe(true)
  1212. expect(model.capabilities.input.image).toBe(true)
  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"].models["basic-model"].capabilities.toolcall).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"].models["model"].capabilities.toolcall).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"].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.id).toEqual(model2.id)
  1419. expect(model1).toEqual(model2)
  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"].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?.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"].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. })
  1671. test("custom model inherits npm package from models.dev provider config", async () => {
  1672. await using tmp = await tmpdir({
  1673. init: async (dir) => {
  1674. await Bun.write(
  1675. path.join(dir, "opencode.json"),
  1676. JSON.stringify({
  1677. $schema: "https://opencode.ai/config.json",
  1678. provider: {
  1679. openai: {
  1680. models: {
  1681. "my-custom-model": {
  1682. name: "My Custom Model",
  1683. tool_call: true,
  1684. limit: { context: 8000, output: 2000 },
  1685. },
  1686. },
  1687. },
  1688. },
  1689. }),
  1690. )
  1691. },
  1692. })
  1693. await Instance.provide({
  1694. directory: tmp.path,
  1695. init: async () => {
  1696. Env.set("OPENAI_API_KEY", "test-api-key")
  1697. },
  1698. fn: async () => {
  1699. const providers = await Provider.list()
  1700. const model = providers["openai"].models["my-custom-model"]
  1701. expect(model).toBeDefined()
  1702. expect(model.api.npm).toBe("@ai-sdk/openai")
  1703. },
  1704. })
  1705. })
  1706. test("custom model inherits api.url from models.dev provider", async () => {
  1707. await using tmp = await tmpdir({
  1708. init: async (dir) => {
  1709. await Bun.write(
  1710. path.join(dir, "opencode.json"),
  1711. JSON.stringify({
  1712. $schema: "https://opencode.ai/config.json",
  1713. provider: {
  1714. openrouter: {
  1715. models: {
  1716. "prime-intellect/intellect-3": {},
  1717. "deepseek/deepseek-r1-0528": {
  1718. name: "DeepSeek R1",
  1719. },
  1720. },
  1721. },
  1722. },
  1723. }),
  1724. )
  1725. },
  1726. })
  1727. await Instance.provide({
  1728. directory: tmp.path,
  1729. init: async () => {
  1730. Env.set("OPENROUTER_API_KEY", "test-api-key")
  1731. },
  1732. fn: async () => {
  1733. const providers = await Provider.list()
  1734. expect(providers["openrouter"]).toBeDefined()
  1735. // New model not in database should inherit api.url from provider
  1736. const intellect = providers["openrouter"].models["prime-intellect/intellect-3"]
  1737. expect(intellect).toBeDefined()
  1738. expect(intellect.api.url).toBe("https://openrouter.ai/api/v1")
  1739. // Another new model should also inherit api.url
  1740. const deepseek = providers["openrouter"].models["deepseek/deepseek-r1-0528"]
  1741. expect(deepseek).toBeDefined()
  1742. expect(deepseek.api.url).toBe("https://openrouter.ai/api/v1")
  1743. expect(deepseek.name).toBe("DeepSeek R1")
  1744. },
  1745. })
  1746. })