catalog.test.ts 15 KB

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