catalog.test.ts 13 KB

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