provider.test.ts 70 KB

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