catalog.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. import { describe, expect } from "bun:test"
  2. import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect"
  3. import { Catalog } from "@opencode-ai/core/catalog"
  4. import { Integration } from "@opencode-ai/core/integration"
  5. import { Credential } from "@opencode-ai/core/credential"
  6. import { EventV2 } from "@opencode-ai/core/event"
  7. import { Location } from "@opencode-ai/core/location"
  8. import { ModelV2 } from "@opencode-ai/core/model"
  9. import { PluginV2 } from "@opencode-ai/core/plugin"
  10. import { Policy } from "@opencode-ai/core/policy"
  11. import { Project } from "@opencode-ai/core/project"
  12. import { ProviderV2 } from "@opencode-ai/core/provider"
  13. import { AbsolutePath } from "@opencode-ai/core/schema"
  14. import { location } from "./fixture/location"
  15. import { testEffect } from "./lib/effect"
  16. const locationLayer = Layer.succeed(
  17. Location.Service,
  18. Location.Service.of(location({ directory: AbsolutePath.make("test") })),
  19. )
  20. const it = testEffect(
  21. Catalog.locationLayer.pipe(
  22. Layer.provideMerge(EventV2.defaultLayer),
  23. Layer.provideMerge(locationLayer),
  24. Layer.provideMerge(
  25. Layer.mock(Credential.Service)({
  26. all: () => Effect.succeed([]),
  27. list: () => Effect.succeed([]),
  28. }),
  29. ),
  30. ),
  31. )
  32. describe("CatalogV2", () => {
  33. it.effect("publishes an updated event after catalog changes", () =>
  34. Effect.gen(function* () {
  35. const catalog = yield* Catalog.Service
  36. const events = yield* EventV2.Service
  37. const updated = yield* events
  38. .subscribe(Catalog.Event.Updated)
  39. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  40. yield* Effect.yieldNow
  41. yield* (yield* catalog.transform())((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
  42. expect((yield* Fiber.join(updated)).length).toBe(1)
  43. }),
  44. )
  45. it.effect("derives availability from active credentials without changing provider state", () => {
  46. const integrationID = Integration.ID.make("test")
  47. const first = {
  48. id: Credential.ID.create(),
  49. integrationID,
  50. label: "First",
  51. value: new Credential.Key({ type: "key", key: "first", metadata: { tenant: "one" } }),
  52. }
  53. const second = {
  54. id: Credential.ID.create(),
  55. integrationID,
  56. label: "Second",
  57. value: new Credential.Key({ type: "key", key: "second", metadata: { tenant: "two" } }),
  58. }
  59. let active = first
  60. const layer = Catalog.locationLayer.pipe(
  61. Layer.fresh,
  62. Layer.provideMerge(EventV2.defaultLayer),
  63. Layer.provideMerge(locationLayer),
  64. Layer.provideMerge(
  65. Layer.mock(Credential.Service)({
  66. all: () => Effect.sync(() => [active]),
  67. list: () => Effect.sync(() => [active]),
  68. }),
  69. ),
  70. )
  71. return Effect.gen(function* () {
  72. const catalog = yield* Catalog.Service
  73. const transform = yield* catalog.transform()
  74. yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
  75. expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
  76. expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
  77. active = second
  78. expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
  79. expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
  80. }).pipe(Effect.provide(layer))
  81. })
  82. it.effect("projects environment connections without a catalog plugin", () =>
  83. Effect.acquireUseRelease(
  84. Effect.sync(() => {
  85. const previous = process.env.CATALOG_TEST_API_KEY
  86. process.env.CATALOG_TEST_API_KEY = "secret"
  87. return previous
  88. }),
  89. () =>
  90. Effect.gen(function* () {
  91. const catalog = yield* Catalog.Service
  92. const integrations = yield* Integration.Service
  93. const providerID = ProviderV2.ID.make("test")
  94. yield* integrations.update((editor) =>
  95. editor.method.update({
  96. integrationID: Integration.ID.make(providerID),
  97. method: { type: "env", names: ["CATALOG_TEST_API_KEY"] },
  98. }),
  99. )
  100. yield* (yield* catalog.transform())((editor) => editor.provider.update(providerID, () => {}))
  101. expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
  102. }),
  103. (previous) =>
  104. Effect.sync(() => {
  105. if (previous === undefined) delete process.env.CATALOG_TEST_API_KEY
  106. else process.env.CATALOG_TEST_API_KEY = previous
  107. }),
  108. ),
  109. )
  110. it.effect("normalizes provider baseURL into api url", () =>
  111. Effect.gen(function* () {
  112. const catalog = yield* Catalog.Service
  113. const providerID = ProviderV2.ID.make("test")
  114. const transform = yield* catalog.transform()
  115. yield* transform((catalog) =>
  116. catalog.provider.update(providerID, (provider) => {
  117. provider.api = {
  118. type: "aisdk",
  119. package: "@ai-sdk/openai-compatible",
  120. url: "https://default.example.com",
  121. }
  122. provider.request.body.baseURL = "https://override.example.com"
  123. }),
  124. )
  125. expect((yield* catalog.provider.get(providerID)).api).toEqual({
  126. type: "aisdk",
  127. package: "@ai-sdk/openai-compatible",
  128. url: "https://override.example.com",
  129. })
  130. }),
  131. )
  132. it.effect("normalizes model baseURL into api url", () =>
  133. Effect.gen(function* () {
  134. const catalog = yield* Catalog.Service
  135. const providerID = ProviderV2.ID.make("test")
  136. const modelID = ModelV2.ID.make("model")
  137. const transform = yield* catalog.transform()
  138. yield* transform((catalog) => {
  139. catalog.provider.update(providerID, (provider) => {
  140. provider.api = {
  141. type: "aisdk",
  142. package: "@ai-sdk/openai-compatible",
  143. url: "https://provider.example.com",
  144. }
  145. })
  146. catalog.model.update(providerID, modelID, (model) => {
  147. model.api = {
  148. id: modelID,
  149. type: "aisdk",
  150. package: "@ai-sdk/openai-compatible",
  151. url: "https://model.example.com",
  152. }
  153. model.request.body.baseURL = "https://override.example.com"
  154. })
  155. })
  156. expect((yield* catalog.model.get(providerID, modelID)).api).toEqual({
  157. id: modelID,
  158. type: "aisdk",
  159. package: "@ai-sdk/openai-compatible",
  160. url: "https://override.example.com",
  161. settings: {},
  162. })
  163. }),
  164. )
  165. it.effect("resolves default model api from provider api", () =>
  166. Effect.gen(function* () {
  167. const catalog = yield* Catalog.Service
  168. const providerID = ProviderV2.ID.make("test")
  169. const modelID = ModelV2.ID.make("model")
  170. const transform = yield* catalog.transform()
  171. yield* transform((catalog) => {
  172. catalog.provider.update(providerID, (provider) => {
  173. provider.api = {
  174. type: "aisdk",
  175. package: "@ai-sdk/openai-compatible",
  176. url: "https://provider.example.com",
  177. }
  178. })
  179. catalog.model.update(providerID, modelID, () => {})
  180. })
  181. expect((yield* catalog.model.get(providerID, modelID)).api).toEqual({
  182. id: modelID,
  183. type: "aisdk",
  184. package: "@ai-sdk/openai-compatible",
  185. url: "https://provider.example.com",
  186. })
  187. }),
  188. )
  189. it.effect("runs catalog transform hooks after baseURL is normalized", () =>
  190. Effect.gen(function* () {
  191. const catalog = yield* Catalog.Service
  192. const plugin = yield* PluginV2.Service
  193. const providerID = ProviderV2.ID.make("test")
  194. const seen: unknown[] = []
  195. const transform = yield* catalog.transform()
  196. yield* plugin.add({
  197. id: PluginV2.ID.make("test"),
  198. effect: Effect.succeed({
  199. "catalog.transform": (evt) =>
  200. Effect.sync(() => {
  201. const item = evt.provider.get(providerID)
  202. if (!item) return
  203. seen.push(item.provider.api.type)
  204. if (item?.provider.api.type === "aisdk") seen.push(item.provider.api.url)
  205. seen.push(item?.provider.request.body.baseURL)
  206. }),
  207. }),
  208. })
  209. yield* transform((catalog) =>
  210. catalog.provider.update(providerID, (provider) => {
  211. provider.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
  212. provider.request.body.baseURL = "https://provider.example.com"
  213. }),
  214. )
  215. expect(seen).toEqual(["aisdk", "https://provider.example.com", undefined])
  216. }),
  217. )
  218. it.effect("runs catalog transform when a plugin is added", () =>
  219. Effect.gen(function* () {
  220. const catalog = yield* Catalog.Service
  221. const plugin = yield* PluginV2.Service
  222. const providerID = ProviderV2.ID.make("test")
  223. const transform = yield* catalog.transform()
  224. yield* transform((catalog) =>
  225. catalog.provider.update(providerID, (provider) => {
  226. provider.name = "Before"
  227. }),
  228. )
  229. yield* plugin.add({
  230. id: PluginV2.ID.make("test-transform"),
  231. effect: Effect.succeed({
  232. "catalog.transform": (evt) =>
  233. Effect.sync(() =>
  234. evt.provider.update(providerID, (provider) => {
  235. provider.name = "After"
  236. }),
  237. ),
  238. }),
  239. })
  240. yield* Effect.yieldNow
  241. expect((yield* catalog.provider.get(providerID)).name).toBe("After")
  242. }),
  243. )
  244. it.effect("ignores plugin additions from another location", () =>
  245. Effect.gen(function* () {
  246. const events = yield* EventV2.Service
  247. const plugin = yield* PluginV2.Service
  248. let invoked = 0
  249. yield* plugin.add({
  250. id: PluginV2.ID.make("test-transform"),
  251. effect: Effect.succeed({
  252. "catalog.transform": () => Effect.sync(() => invoked++),
  253. }),
  254. })
  255. yield* Effect.yieldNow
  256. expect(invoked).toBe(1)
  257. yield* events.publish(
  258. PluginV2.Event.Added,
  259. { id: PluginV2.ID.make("test-transform") },
  260. {
  261. location: new Location.Info({
  262. directory: AbsolutePath.make("other"),
  263. project: { id: Project.ID.global, directory: AbsolutePath.make("other") },
  264. }),
  265. },
  266. )
  267. yield* Effect.yieldNow
  268. expect(invoked).toBe(1)
  269. }),
  270. )
  271. it.effect("resolves provider and model request merges", () =>
  272. Effect.gen(function* () {
  273. const catalog = yield* Catalog.Service
  274. const providerID = ProviderV2.ID.make("test")
  275. const modelID = ModelV2.ID.make("model")
  276. const transform = yield* catalog.transform()
  277. yield* transform((catalog) => {
  278. catalog.provider.update(providerID, (provider) => {
  279. provider.request.headers.provider = "provider"
  280. provider.request.headers.shared = "provider"
  281. provider.request.body.provider = true
  282. })
  283. catalog.model.update(providerID, modelID, (model) => {
  284. model.request.headers.model = "model"
  285. model.request.headers.shared = "model"
  286. model.request.body.model = true
  287. model.request.body.request = true
  288. const options = (model.request.options ??= {})
  289. options.shared = "model"
  290. options.model = true
  291. })
  292. })
  293. const model = yield* catalog.model.get(providerID, modelID)
  294. expect(model.request.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
  295. expect(model.request.body).toEqual({ provider: true, model: true, request: true })
  296. expect(model.request.options).toEqual({ shared: "model", model: true })
  297. }),
  298. )
  299. it.effect("falls back to newest available model when no default is configured", () =>
  300. Effect.gen(function* () {
  301. const catalog = yield* Catalog.Service
  302. const providerID = ProviderV2.ID.make("test")
  303. const transform = yield* catalog.transform()
  304. yield* transform((catalog) => {
  305. catalog.provider.update(providerID, () => {})
  306. catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
  307. model.time.released = DateTime.makeUnsafe(1000)
  308. })
  309. catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => {
  310. model.time.released = DateTime.makeUnsafe(2000)
  311. })
  312. })
  313. expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toMatch("new")
  314. }),
  315. )
  316. it.effect("uses a transform-provided default model until that transform is replaced", () =>
  317. Effect.gen(function* () {
  318. const catalog = yield* Catalog.Service
  319. const providerID = ProviderV2.ID.make("test")
  320. const old = ModelV2.ID.make("old")
  321. const newest = ModelV2.ID.make("new")
  322. const transform = yield* catalog.transform()
  323. const models = (catalog: Catalog.Editor) => {
  324. catalog.provider.update(providerID, () => {})
  325. catalog.model.update(providerID, old, (model) => {
  326. model.time.released = DateTime.makeUnsafe(1000)
  327. })
  328. catalog.model.update(providerID, newest, (model) => {
  329. model.time.released = DateTime.makeUnsafe(2000)
  330. })
  331. }
  332. yield* transform((catalog) => {
  333. models(catalog)
  334. catalog.model.default.set(providerID, old)
  335. })
  336. expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(old)
  337. yield* transform(models)
  338. expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(newest)
  339. }),
  340. )
  341. it.effect("ignores a configured default on a disabled provider", () =>
  342. Effect.gen(function* () {
  343. const catalog = yield* Catalog.Service
  344. const disabledProvider = ProviderV2.ID.make("disabled")
  345. const enabledProvider = ProviderV2.ID.make("enabled")
  346. const disabledModel = ModelV2.ID.make("configured")
  347. const fallbackModel = ModelV2.ID.make("fallback")
  348. const transform = yield* catalog.transform()
  349. yield* transform((catalog) => {
  350. catalog.provider.update(disabledProvider, (provider) => {
  351. provider.disabled = true
  352. })
  353. catalog.model.update(disabledProvider, disabledModel, () => {})
  354. catalog.provider.update(enabledProvider, () => {})
  355. catalog.model.update(enabledProvider, fallbackModel, () => {})
  356. catalog.model.default.set(disabledProvider, disabledModel)
  357. })
  358. expect(Option.getOrUndefined(yield* catalog.model.default())).toMatchObject({
  359. providerID: enabledProvider,
  360. id: fallbackModel,
  361. })
  362. }),
  363. )
  364. it.effect("small model prefers small keyword candidates before cost scoring", () =>
  365. Effect.gen(function* () {
  366. const catalog = yield* Catalog.Service
  367. const providerID = ProviderV2.ID.make("test")
  368. const transform = yield* catalog.transform()
  369. yield* transform((catalog) => {
  370. catalog.provider.update(providerID, () => {})
  371. catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
  372. model.capabilities.input = ["text"]
  373. model.capabilities.output = ["text"]
  374. model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }]
  375. model.time.released = DateTime.makeUnsafe(Date.now())
  376. })
  377. catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
  378. model.capabilities.input = ["text"]
  379. model.capabilities.output = ["text"]
  380. model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }]
  381. model.time.released = DateTime.makeUnsafe(Date.now())
  382. })
  383. })
  384. expect(Option.getOrUndefined(yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
  385. }),
  386. )
  387. it.effect("removes providers denied by policy after loading", () =>
  388. Effect.gen(function* () {
  389. const catalog = yield* Catalog.Service
  390. const policy = yield* Policy.Service
  391. const providerID = ProviderV2.ID.make("blocked")
  392. const transform = yield* catalog.transform()
  393. yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })])
  394. yield* transform((catalog) => {
  395. catalog.provider.update(providerID, () => {})
  396. catalog.model.update(providerID, ModelV2.ID.make("model"), () => {})
  397. })
  398. expect(yield* catalog.provider.all()).toEqual([])
  399. expect(yield* catalog.model.all()).toEqual([])
  400. expect(yield* catalog.provider.get(providerID).pipe(Effect.option)).toEqual(Option.none())
  401. }),
  402. )
  403. })