catalog.test.ts 11 KB

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