catalog.test.ts 15 KB

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