catalog.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  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: Credential.Key.make({ 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: Credential.Key.make({ 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("derives availability from a provider's integration", () => {
  71. const integrationID = Integration.ID.make("gateway")
  72. const providerID = ProviderV2.ID.make("remote")
  73. const layer = Catalog.locationLayer.pipe(
  74. Layer.fresh,
  75. Layer.provideMerge(EventV2.defaultLayer),
  76. Layer.provideMerge(locationLayer),
  77. Layer.provideMerge(Credential.defaultLayer.pipe(Layer.fresh)),
  78. )
  79. return Effect.gen(function* () {
  80. const catalog = yield* Catalog.Service
  81. yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {}))
  82. yield* catalog.transform((editor) =>
  83. editor.provider.update(providerID, (provider) => {
  84. provider.integrationID = integrationID
  85. }),
  86. )
  87. expect(yield* catalog.provider.available()).toEqual([])
  88. yield* (yield* Credential.Service).create({
  89. integrationID,
  90. value: Credential.Key.make({ type: "key", key: "secret" }),
  91. })
  92. expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([providerID])
  93. }).pipe(Effect.provide(layer))
  94. })
  95. it.effect("projects environment connections without a catalog plugin", () =>
  96. Effect.acquireUseRelease(
  97. Effect.sync(() => {
  98. const previous = process.env.CATALOG_TEST_API_KEY
  99. process.env.CATALOG_TEST_API_KEY = "secret"
  100. return previous
  101. }),
  102. () =>
  103. Effect.gen(function* () {
  104. const catalog = yield* Catalog.Service
  105. const integrations = yield* Integration.Service
  106. const providerID = ProviderV2.ID.make("test")
  107. yield* integrations.transform((editor) =>
  108. editor.method.update({
  109. integrationID: Integration.ID.make(providerID),
  110. method: { type: "env", names: ["CATALOG_TEST_API_KEY"] },
  111. }),
  112. )
  113. yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
  114. expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
  115. }),
  116. (previous) =>
  117. Effect.sync(() => {
  118. if (previous === undefined) delete process.env.CATALOG_TEST_API_KEY
  119. else process.env.CATALOG_TEST_API_KEY = previous
  120. }),
  121. ),
  122. )
  123. it.effect("normalizes provider baseURL into api url", () =>
  124. Effect.gen(function* () {
  125. const catalog = yield* Catalog.Service
  126. const providerID = ProviderV2.ID.make("test")
  127. yield* catalog.transform((catalog) =>
  128. catalog.provider.update(providerID, (provider) => {
  129. provider.api = {
  130. type: "aisdk",
  131. package: "@ai-sdk/openai-compatible",
  132. url: "https://default.example.com",
  133. }
  134. provider.request.body.baseURL = "https://override.example.com"
  135. }),
  136. )
  137. expect(required(yield* catalog.provider.get(providerID)).api).toEqual({
  138. type: "aisdk",
  139. package: "@ai-sdk/openai-compatible",
  140. url: "https://override.example.com",
  141. })
  142. }),
  143. )
  144. it.effect("normalizes model baseURL into api url", () =>
  145. Effect.gen(function* () {
  146. const catalog = yield* Catalog.Service
  147. const providerID = ProviderV2.ID.make("test")
  148. const modelID = ModelV2.ID.make("model")
  149. yield* catalog.transform((catalog) => {
  150. catalog.provider.update(providerID, (provider) => {
  151. provider.api = {
  152. type: "aisdk",
  153. package: "@ai-sdk/openai-compatible",
  154. url: "https://provider.example.com",
  155. }
  156. })
  157. catalog.model.update(providerID, modelID, (model) => {
  158. model.api = {
  159. id: modelID,
  160. type: "aisdk",
  161. package: "@ai-sdk/openai-compatible",
  162. url: "https://model.example.com",
  163. }
  164. model.request.body.baseURL = "https://override.example.com"
  165. })
  166. })
  167. expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({
  168. id: modelID,
  169. type: "aisdk",
  170. package: "@ai-sdk/openai-compatible",
  171. url: "https://override.example.com",
  172. settings: {},
  173. })
  174. }),
  175. )
  176. it.effect("resolves default model api from provider api", () =>
  177. Effect.gen(function* () {
  178. const catalog = yield* Catalog.Service
  179. const providerID = ProviderV2.ID.make("test")
  180. const modelID = ModelV2.ID.make("model")
  181. yield* catalog.transform((catalog) => {
  182. catalog.provider.update(providerID, (provider) => {
  183. provider.api = {
  184. type: "aisdk",
  185. package: "@ai-sdk/openai-compatible",
  186. url: "https://provider.example.com",
  187. }
  188. })
  189. catalog.model.update(providerID, modelID, () => {})
  190. })
  191. expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({
  192. id: modelID,
  193. type: "aisdk",
  194. package: "@ai-sdk/openai-compatible",
  195. url: "https://provider.example.com",
  196. })
  197. }),
  198. )
  199. it.effect("resolves provider and model request merges", () =>
  200. Effect.gen(function* () {
  201. const catalog = yield* Catalog.Service
  202. const providerID = ProviderV2.ID.make("test")
  203. const modelID = ModelV2.ID.make("model")
  204. yield* catalog.transform((catalog) => {
  205. catalog.provider.update(providerID, (provider) => {
  206. provider.request.headers.provider = "provider"
  207. provider.request.headers.shared = "provider"
  208. provider.request.body.provider = true
  209. })
  210. catalog.model.update(providerID, modelID, (model) => {
  211. model.request.headers.model = "model"
  212. model.request.headers.shared = "model"
  213. model.request.body.model = true
  214. model.request.body.request = true
  215. model.request.body.shared = "model"
  216. })
  217. })
  218. const model = required(yield* catalog.model.get(providerID, modelID))
  219. expect(model.request.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
  220. expect(model.request.body).toEqual({ provider: true, model: true, request: true, shared: "model" })
  221. }),
  222. )
  223. it.effect("falls back to newest available model when no default is configured", () =>
  224. Effect.gen(function* () {
  225. const catalog = yield* Catalog.Service
  226. const providerID = ProviderV2.ID.make("test")
  227. yield* catalog.transform((catalog) => {
  228. catalog.provider.update(providerID, () => {})
  229. catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
  230. model.time.released = 1000
  231. })
  232. catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => {
  233. model.time.released = 2000
  234. })
  235. })
  236. expect((yield* catalog.model.default())?.id).toMatch("new")
  237. }),
  238. )
  239. it.effect("uses a transform-provided default model until that transform is replaced", () =>
  240. Effect.gen(function* () {
  241. const catalog = yield* Catalog.Service
  242. const providerID = ProviderV2.ID.make("test")
  243. const old = ModelV2.ID.make("old")
  244. const newest = ModelV2.ID.make("new")
  245. const models = (catalog: Catalog.Draft) => {
  246. catalog.provider.update(providerID, () => {})
  247. catalog.model.update(providerID, old, (model) => {
  248. model.time.released = 1000
  249. })
  250. catalog.model.update(providerID, newest, (model) => {
  251. model.time.released = 2000
  252. })
  253. }
  254. let configured = true
  255. yield* catalog.transform((catalog) => {
  256. models(catalog)
  257. if (configured) catalog.model.default.set(providerID, old)
  258. })
  259. expect((yield* catalog.model.default())?.id).toBe(old)
  260. configured = false
  261. yield* catalog.reload()
  262. expect((yield* catalog.model.default())?.id).toBe(newest)
  263. }),
  264. )
  265. it.effect("ignores a configured default on a disabled provider", () =>
  266. Effect.gen(function* () {
  267. const catalog = yield* Catalog.Service
  268. const disabledProvider = ProviderV2.ID.make("disabled")
  269. const enabledProvider = ProviderV2.ID.make("enabled")
  270. const disabledModel = ModelV2.ID.make("configured")
  271. const fallbackModel = ModelV2.ID.make("fallback")
  272. yield* catalog.transform((catalog) => {
  273. catalog.provider.update(disabledProvider, (provider) => {
  274. provider.disabled = true
  275. })
  276. catalog.model.update(disabledProvider, disabledModel, () => {})
  277. catalog.provider.update(enabledProvider, () => {})
  278. catalog.model.update(enabledProvider, fallbackModel, () => {})
  279. catalog.model.default.set(disabledProvider, disabledModel)
  280. })
  281. expect(yield* catalog.model.default()).toMatchObject({
  282. providerID: enabledProvider,
  283. id: fallbackModel,
  284. })
  285. }),
  286. )
  287. it.effect("small model prefers small keyword candidates before cost scoring", () =>
  288. Effect.gen(function* () {
  289. const catalog = yield* Catalog.Service
  290. const providerID = ProviderV2.ID.make("test")
  291. yield* catalog.transform((catalog) => {
  292. catalog.provider.update(providerID, () => {})
  293. catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
  294. model.capabilities.input = ["text"]
  295. model.capabilities.output = ["text"]
  296. model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }]
  297. model.time.released = Date.now()
  298. })
  299. catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
  300. model.capabilities.input = ["text"]
  301. model.capabilities.output = ["text"]
  302. model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }]
  303. model.time.released = Date.now()
  304. })
  305. })
  306. expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
  307. }),
  308. )
  309. it.effect("removes providers denied by policy after loading", () =>
  310. Effect.gen(function* () {
  311. const catalog = yield* Catalog.Service
  312. const policy = yield* Policy.Service
  313. const providerID = ProviderV2.ID.make("blocked")
  314. yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })])
  315. yield* catalog.transform((catalog) => {
  316. catalog.provider.update(providerID, () => {})
  317. catalog.model.update(providerID, ModelV2.ID.make("model"), () => {})
  318. })
  319. expect(yield* catalog.provider.all()).toEqual([])
  320. expect(yield* catalog.model.all()).toEqual([])
  321. expect(yield* catalog.provider.get(providerID)).toBeUndefined()
  322. }),
  323. )
  324. })