catalog.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Fiber, Layer, Stream } 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 { Policy } from "@opencode-ai/core/policy"
  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. function required<T>(value: T | undefined): T {
  15. if (value === undefined) throw new Error("Expected value")
  16. return value
  17. }
  18. const locationLayer = Layer.succeed(
  19. Location.Service,
  20. Location.Service.of(location({ directory: AbsolutePath.make("test") })),
  21. )
  22. const it = testEffect(
  23. Catalog.locationLayer.pipe(
  24. Layer.provideMerge(EventV2.defaultLayer),
  25. Layer.provideMerge(locationLayer),
  26. Layer.provideMerge(Credential.defaultLayer),
  27. ),
  28. )
  29. describe("CatalogV2", () => {
  30. it.effect("publishes an updated event after catalog changes", () =>
  31. Effect.gen(function* () {
  32. const catalog = yield* Catalog.Service
  33. const events = yield* EventV2.Service
  34. const updated = yield* events
  35. .subscribe(Catalog.Event.Updated)
  36. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  37. yield* Effect.yieldNow
  38. yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
  39. expect((yield* Fiber.join(updated)).length).toBe(1)
  40. }),
  41. )
  42. it.effect("derives availability from active credentials without changing provider state", () => {
  43. const integrationID = Integration.ID.make("test")
  44. const layer = Catalog.locationLayer.pipe(
  45. Layer.fresh,
  46. Layer.provideMerge(EventV2.defaultLayer),
  47. Layer.provideMerge(locationLayer),
  48. Layer.provideMerge(Credential.defaultLayer.pipe(Layer.fresh)),
  49. )
  50. return Effect.gen(function* () {
  51. const catalog = yield* Catalog.Service
  52. const credentials = yield* Credential.Service
  53. yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
  54. yield* credentials.create({
  55. integrationID,
  56. label: "First",
  57. value: new Credential.Key({ type: "key", key: "first", metadata: { tenant: "one" } }),
  58. })
  59. expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
  60. expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
  61. yield* credentials.create({
  62. integrationID,
  63. label: "Second",
  64. value: new Credential.Key({ type: "key", key: "second", metadata: { tenant: "two" } }),
  65. })
  66. expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
  67. expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
  68. }).pipe(Effect.provide(layer))
  69. })
  70. it.effect("projects environment connections without a catalog plugin", () =>
  71. Effect.acquireUseRelease(
  72. Effect.sync(() => {
  73. const previous = process.env.CATALOG_TEST_API_KEY
  74. process.env.CATALOG_TEST_API_KEY = "secret"
  75. return previous
  76. }),
  77. () =>
  78. Effect.gen(function* () {
  79. const catalog = yield* Catalog.Service
  80. const integrations = yield* Integration.Service
  81. const providerID = ProviderV2.ID.make("test")
  82. yield* integrations.transform((editor) =>
  83. editor.method.update({
  84. integrationID: Integration.ID.make(providerID),
  85. method: { type: "env", names: ["CATALOG_TEST_API_KEY"] },
  86. }),
  87. )
  88. yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
  89. expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
  90. }),
  91. (previous) =>
  92. Effect.sync(() => {
  93. if (previous === undefined) delete process.env.CATALOG_TEST_API_KEY
  94. else process.env.CATALOG_TEST_API_KEY = previous
  95. }),
  96. ),
  97. )
  98. it.effect("normalizes provider baseURL into api url", () =>
  99. Effect.gen(function* () {
  100. const catalog = yield* Catalog.Service
  101. const providerID = ProviderV2.ID.make("test")
  102. yield* catalog.transform((catalog) =>
  103. catalog.provider.update(providerID, (provider) => {
  104. provider.api = {
  105. type: "aisdk",
  106. package: "@ai-sdk/openai-compatible",
  107. url: "https://default.example.com",
  108. }
  109. provider.request.body.baseURL = "https://override.example.com"
  110. }),
  111. )
  112. expect(required(yield* catalog.provider.get(providerID)).api).toEqual({
  113. type: "aisdk",
  114. package: "@ai-sdk/openai-compatible",
  115. url: "https://override.example.com",
  116. })
  117. }),
  118. )
  119. it.effect("normalizes model baseURL into api url", () =>
  120. Effect.gen(function* () {
  121. const catalog = yield* Catalog.Service
  122. const providerID = ProviderV2.ID.make("test")
  123. const modelID = ModelV2.ID.make("model")
  124. yield* catalog.transform((catalog) => {
  125. catalog.provider.update(providerID, (provider) => {
  126. provider.api = {
  127. type: "aisdk",
  128. package: "@ai-sdk/openai-compatible",
  129. url: "https://provider.example.com",
  130. }
  131. })
  132. catalog.model.update(providerID, modelID, (model) => {
  133. model.api = {
  134. id: modelID,
  135. type: "aisdk",
  136. package: "@ai-sdk/openai-compatible",
  137. url: "https://model.example.com",
  138. }
  139. model.request.body.baseURL = "https://override.example.com"
  140. })
  141. })
  142. expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({
  143. id: modelID,
  144. type: "aisdk",
  145. package: "@ai-sdk/openai-compatible",
  146. url: "https://override.example.com",
  147. settings: {},
  148. })
  149. }),
  150. )
  151. it.effect("resolves default model api from provider api", () =>
  152. Effect.gen(function* () {
  153. const catalog = yield* Catalog.Service
  154. const providerID = ProviderV2.ID.make("test")
  155. const modelID = ModelV2.ID.make("model")
  156. yield* catalog.transform((catalog) => {
  157. catalog.provider.update(providerID, (provider) => {
  158. provider.api = {
  159. type: "aisdk",
  160. package: "@ai-sdk/openai-compatible",
  161. url: "https://provider.example.com",
  162. }
  163. })
  164. catalog.model.update(providerID, modelID, () => {})
  165. })
  166. expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({
  167. id: modelID,
  168. type: "aisdk",
  169. package: "@ai-sdk/openai-compatible",
  170. url: "https://provider.example.com",
  171. })
  172. }),
  173. )
  174. it.effect("resolves provider and model request merges", () =>
  175. Effect.gen(function* () {
  176. const catalog = yield* Catalog.Service
  177. const providerID = ProviderV2.ID.make("test")
  178. const modelID = ModelV2.ID.make("model")
  179. yield* catalog.transform((catalog) => {
  180. catalog.provider.update(providerID, (provider) => {
  181. provider.request.headers.provider = "provider"
  182. provider.request.headers.shared = "provider"
  183. provider.request.body.provider = true
  184. })
  185. catalog.model.update(providerID, modelID, (model) => {
  186. model.request.headers.model = "model"
  187. model.request.headers.shared = "model"
  188. model.request.body.model = true
  189. model.request.body.request = true
  190. const options = (model.request.options ??= {})
  191. options.shared = "model"
  192. options.model = true
  193. })
  194. })
  195. const model = required(yield* catalog.model.get(providerID, modelID))
  196. expect(model.request.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
  197. expect(model.request.body).toEqual({ provider: true, model: true, request: true })
  198. expect(model.request.options).toEqual({ shared: "model", model: 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. yield* catalog.transform((catalog) => {
  206. catalog.provider.update(providerID, () => {})
  207. catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
  208. model.time.released = 1000
  209. })
  210. catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => {
  211. model.time.released = 2000
  212. })
  213. })
  214. expect((yield* catalog.model.default())?.id).toMatch("new")
  215. }),
  216. )
  217. it.effect("uses a transform-provided default model until that transform is replaced", () =>
  218. Effect.gen(function* () {
  219. const catalog = yield* Catalog.Service
  220. const providerID = ProviderV2.ID.make("test")
  221. const old = ModelV2.ID.make("old")
  222. const newest = ModelV2.ID.make("new")
  223. const models = (catalog: Catalog.Draft) => {
  224. catalog.provider.update(providerID, () => {})
  225. catalog.model.update(providerID, old, (model) => {
  226. model.time.released = 1000
  227. })
  228. catalog.model.update(providerID, newest, (model) => {
  229. model.time.released = 2000
  230. })
  231. }
  232. let configured = true
  233. yield* catalog.transform((catalog) => {
  234. models(catalog)
  235. if (configured) catalog.model.default.set(providerID, old)
  236. })
  237. expect((yield* catalog.model.default())?.id).toBe(old)
  238. configured = false
  239. yield* catalog.rebuild()
  240. expect((yield* catalog.model.default())?.id).toBe(newest)
  241. }),
  242. )
  243. it.effect("ignores a configured default on a disabled provider", () =>
  244. Effect.gen(function* () {
  245. const catalog = yield* Catalog.Service
  246. const disabledProvider = ProviderV2.ID.make("disabled")
  247. const enabledProvider = ProviderV2.ID.make("enabled")
  248. const disabledModel = ModelV2.ID.make("configured")
  249. const fallbackModel = ModelV2.ID.make("fallback")
  250. yield* catalog.transform((catalog) => {
  251. catalog.provider.update(disabledProvider, (provider) => {
  252. provider.disabled = true
  253. })
  254. catalog.model.update(disabledProvider, disabledModel, () => {})
  255. catalog.provider.update(enabledProvider, () => {})
  256. catalog.model.update(enabledProvider, fallbackModel, () => {})
  257. catalog.model.default.set(disabledProvider, disabledModel)
  258. })
  259. expect(yield* catalog.model.default()).toMatchObject({
  260. providerID: enabledProvider,
  261. id: fallbackModel,
  262. })
  263. }),
  264. )
  265. it.effect("small model prefers small keyword candidates before cost scoring", () =>
  266. Effect.gen(function* () {
  267. const catalog = yield* Catalog.Service
  268. const providerID = ProviderV2.ID.make("test")
  269. yield* catalog.transform((catalog) => {
  270. catalog.provider.update(providerID, () => {})
  271. catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
  272. model.capabilities.input = ["text"]
  273. model.capabilities.output = ["text"]
  274. model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }]
  275. model.time.released = Date.now()
  276. })
  277. catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
  278. model.capabilities.input = ["text"]
  279. model.capabilities.output = ["text"]
  280. model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }]
  281. model.time.released = Date.now()
  282. })
  283. })
  284. expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
  285. }),
  286. )
  287. it.effect("removes providers denied by policy after loading", () =>
  288. Effect.gen(function* () {
  289. const catalog = yield* Catalog.Service
  290. const policy = yield* Policy.Service
  291. const providerID = ProviderV2.ID.make("blocked")
  292. yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })])
  293. yield* catalog.transform((catalog) => {
  294. catalog.provider.update(providerID, () => {})
  295. catalog.model.update(providerID, ModelV2.ID.make("model"), () => {})
  296. })
  297. expect(yield* catalog.provider.all()).toEqual([])
  298. expect(yield* catalog.model.all()).toEqual([])
  299. expect(yield* catalog.provider.get(providerID)).toBeUndefined()
  300. }),
  301. )
  302. })