catalog.test.ts 11 KB

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