catalog.test.ts 12 KB

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