provider.test.ts 57 KB

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