provider.test.ts 65 KB

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