catalog.test.ts 13 KB

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