catalog.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  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("makes an explicitly enabled provider available without a connection", () => {
  93. const integrationID = Integration.ID.make("gateway")
  94. const providerID = Provider.ID.make("remote")
  95. const localCatalogLayer = Layer.fresh(
  96. AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [
  97. [Location.node, locationLayer],
  98. ]),
  99. )
  100. return Effect.gen(function* () {
  101. const catalog = yield* Catalog.Service
  102. yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {}))
  103. yield* catalog.transform((editor) =>
  104. editor.provider.update(providerID, (provider) => {
  105. provider.integrationID = integrationID
  106. provider.settings = { baseURL: "https://gateway.example.com/v1" }
  107. }),
  108. )
  109. expect(yield* catalog.provider.available()).toEqual([])
  110. yield* catalog.transform((editor) =>
  111. editor.provider.update(providerID, (provider) => {
  112. provider.activation = "enabled"
  113. }),
  114. )
  115. expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([providerID])
  116. }).pipe(Effect.provide(localCatalogLayer))
  117. })
  118. it.effect("projects environment connections without a catalog plugin", () =>
  119. Effect.acquireUseRelease(
  120. Effect.sync(() => {
  121. const previous = process.env.CATALOG_TEST_API_KEY
  122. process.env.CATALOG_TEST_API_KEY = "secret"
  123. return previous
  124. }),
  125. () =>
  126. Effect.gen(function* () {
  127. const catalog = yield* Catalog.Service
  128. const integrations = yield* Integration.Service
  129. const providerID = Provider.ID.make("test")
  130. yield* integrations.transform((editor) =>
  131. editor.method.update({
  132. integrationID: Integration.ID.make(providerID),
  133. method: { type: "env", names: ["CATALOG_TEST_API_KEY"] },
  134. }),
  135. )
  136. yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
  137. expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
  138. }),
  139. (previous) =>
  140. Effect.sync(() => {
  141. if (previous === undefined) delete process.env.CATALOG_TEST_API_KEY
  142. else process.env.CATALOG_TEST_API_KEY = previous
  143. }),
  144. ),
  145. )
  146. it.effect("stores provider package settings", () =>
  147. Effect.gen(function* () {
  148. const catalog = yield* Catalog.Service
  149. const providerID = Provider.ID.make("test")
  150. yield* catalog.transform((catalog) =>
  151. catalog.provider.update(providerID, (provider) => {
  152. provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
  153. provider.settings = { baseURL: "https://override.example.com" }
  154. }),
  155. )
  156. expect(required(yield* catalog.provider.get(providerID))).toMatchObject({
  157. package: Provider.aisdk("@ai-sdk/openai-compatible"),
  158. settings: { baseURL: "https://override.example.com" },
  159. })
  160. }),
  161. )
  162. it.effect("uses model package settings over provider settings", () =>
  163. Effect.gen(function* () {
  164. const catalog = yield* Catalog.Service
  165. const providerID = Provider.ID.make("test")
  166. const modelID = Model.ID.make("model")
  167. yield* catalog.transform((catalog) => {
  168. catalog.provider.update(providerID, (provider) => {
  169. provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
  170. provider.settings = { baseURL: "https://provider.example.com" }
  171. })
  172. catalog.model.update(providerID, modelID, (model) => {
  173. model.modelID = Model.ID.make("upstream-model")
  174. model.package = Provider.aisdk("@ai-sdk/openai-compatible")
  175. model.settings = { baseURL: "https://override.example.com" }
  176. })
  177. })
  178. expect(required(yield* catalog.model.get(providerID, modelID))).toMatchObject({
  179. modelID: Model.ID.make("upstream-model"),
  180. package: Provider.aisdk("@ai-sdk/openai-compatible"),
  181. settings: { baseURL: "https://override.example.com" },
  182. })
  183. }),
  184. )
  185. it.effect("resolves default model package settings from the provider", () =>
  186. Effect.gen(function* () {
  187. const catalog = yield* Catalog.Service
  188. const providerID = Provider.ID.make("test")
  189. const modelID = Model.ID.make("model")
  190. yield* catalog.transform((catalog) => {
  191. catalog.provider.update(providerID, (provider) => {
  192. provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
  193. provider.settings = { baseURL: "https://provider.example.com" }
  194. })
  195. catalog.model.update(providerID, modelID, () => {})
  196. })
  197. expect(required(yield* catalog.model.get(providerID, modelID))).toMatchObject({
  198. package: Provider.aisdk("@ai-sdk/openai-compatible"),
  199. settings: { baseURL: "https://provider.example.com" },
  200. })
  201. }),
  202. )
  203. it.effect("resolves provider and model overlay merges", () =>
  204. Effect.gen(function* () {
  205. const catalog = yield* Catalog.Service
  206. const providerID = Provider.ID.make("test")
  207. const modelID = Model.ID.make("model")
  208. yield* catalog.transform((catalog) => {
  209. catalog.provider.update(providerID, (provider) => {
  210. provider.settings = { provider: true, shared: "provider" }
  211. provider.headers = { provider: "provider", shared: "provider" }
  212. provider.body = { provider: true, shared: "provider" }
  213. })
  214. catalog.model.update(providerID, modelID, (model) => {
  215. model.settings = { model: true, shared: "model" }
  216. model.headers = { model: "model", shared: "model" }
  217. model.body = { model: true, shared: "model" }
  218. })
  219. })
  220. const model = required(yield* catalog.model.get(providerID, modelID))
  221. expect(model.settings).toEqual({ provider: true, shared: "model", model: true })
  222. expect(model.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
  223. expect(model.body).toEqual({ provider: true, shared: "model", model: true })
  224. }),
  225. )
  226. it.effect("falls back to newest available model when no default is configured", () =>
  227. Effect.gen(function* () {
  228. const catalog = yield* Catalog.Service
  229. const providerID = Provider.ID.make("test")
  230. yield* catalog.transform((catalog) => {
  231. catalog.provider.update(providerID, () => {})
  232. catalog.model.update(providerID, Model.ID.make("old"), (model) => {
  233. model.time.released = 1000
  234. })
  235. catalog.model.update(providerID, Model.ID.make("new"), (model) => {
  236. model.time.released = 2000
  237. })
  238. })
  239. expect((yield* catalog.model.default())?.id).toMatch("new")
  240. }),
  241. )
  242. it.effect("uses a transform-provided default model until that transform is replaced", () =>
  243. Effect.gen(function* () {
  244. const catalog = yield* Catalog.Service
  245. const providerID = Provider.ID.make("test")
  246. const old = Model.ID.make("old")
  247. const newest = Model.ID.make("new")
  248. const models = (catalog: Catalog.Draft) => {
  249. catalog.provider.update(providerID, () => {})
  250. catalog.model.update(providerID, old, (model) => {
  251. model.time.released = 1000
  252. })
  253. catalog.model.update(providerID, newest, (model) => {
  254. model.time.released = 2000
  255. })
  256. }
  257. let configured = true
  258. yield* catalog.transform((catalog) => {
  259. models(catalog)
  260. if (configured) catalog.model.default.set(providerID, old)
  261. })
  262. expect((yield* catalog.model.default())?.id).toBe(old)
  263. configured = false
  264. const reload = yield* catalog.reload().pipe(Effect.forkChild({ startImmediately: true }))
  265. yield* TestClock.adjust("500 millis")
  266. yield* Fiber.join(reload)
  267. expect((yield* catalog.model.default())?.id).toBe(newest)
  268. }),
  269. )
  270. it.effect("ignores a configured default on a disabled provider", () =>
  271. Effect.gen(function* () {
  272. const catalog = yield* Catalog.Service
  273. const disabledProvider = Provider.ID.make("disabled")
  274. const enabledProvider = Provider.ID.make("enabled")
  275. const disabledModel = Model.ID.make("configured")
  276. const fallbackModel = Model.ID.make("fallback")
  277. yield* catalog.transform((catalog) => {
  278. catalog.provider.update(disabledProvider, (provider) => {
  279. provider.activation = "disabled"
  280. })
  281. catalog.model.update(disabledProvider, disabledModel, () => {})
  282. catalog.provider.update(enabledProvider, () => {})
  283. catalog.model.update(enabledProvider, fallbackModel, () => {})
  284. catalog.model.default.set(disabledProvider, disabledModel)
  285. })
  286. expect(yield* catalog.model.default()).toMatchObject({
  287. providerID: enabledProvider,
  288. id: fallbackModel,
  289. })
  290. }),
  291. )
  292. it.effect("small model prefers small keyword candidates before cost scoring", () =>
  293. Effect.gen(function* () {
  294. const catalog = yield* Catalog.Service
  295. const providerID = Provider.ID.make("test")
  296. yield* catalog.transform((catalog) => {
  297. catalog.provider.update(providerID, () => {})
  298. catalog.model.update(providerID, Model.ID.make("cheap-large"), (model) => {
  299. model.capabilities.input = ["text"]
  300. model.capabilities.output = ["text"]
  301. model.cost = [
  302. {
  303. input: Money.USDPerMillionTokens.make(1),
  304. output: Money.USDPerMillionTokens.make(1),
  305. cache: {
  306. read: Money.USDPerMillionTokens.zero,
  307. write: Money.USDPerMillionTokens.zero,
  308. },
  309. },
  310. ]
  311. model.time.released = Date.now()
  312. })
  313. catalog.model.update(providerID, Model.ID.make("expensive-mini"), (model) => {
  314. model.capabilities.input = ["text"]
  315. model.capabilities.output = ["text"]
  316. model.cost = [
  317. {
  318. input: Money.USDPerMillionTokens.make(10),
  319. output: Money.USDPerMillionTokens.make(10),
  320. cache: {
  321. read: Money.USDPerMillionTokens.zero,
  322. write: Money.USDPerMillionTokens.zero,
  323. },
  324. },
  325. ]
  326. model.time.released = Date.now()
  327. })
  328. })
  329. expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
  330. }),
  331. )
  332. })