provider.test.ts 62 KB

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