1
0

catalog.test.ts 13 KB

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