catalog.test.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. import { describe, expect } from "bun:test"
  2. import { DateTime, Effect, Layer, Option } from "effect"
  3. import { Catalog } from "@opencode-ai/core/catalog"
  4. import { EventV2 } from "@opencode-ai/core/event"
  5. import { Location } from "@opencode-ai/core/location"
  6. import { ModelV2 } from "@opencode-ai/core/model"
  7. import { PluginV2 } from "@opencode-ai/core/plugin"
  8. import { ProviderV2 } from "@opencode-ai/core/provider"
  9. import { testEffect } from "./lib/effect"
  10. const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
  11. const it = testEffect(
  12. Catalog.layer.pipe(
  13. Layer.provideMerge(EventV2.defaultLayer),
  14. Layer.provideMerge(PluginV2.defaultLayer),
  15. Layer.provideMerge(locationLayer),
  16. ),
  17. )
  18. describe("CatalogV2", () => {
  19. it.effect("normalizes provider baseURL into endpoint url", () =>
  20. Effect.gen(function* () {
  21. const catalog = yield* Catalog.Service
  22. const providerID = ProviderV2.ID.make("test")
  23. const load = yield* catalog.loader()
  24. yield* load((catalog) =>
  25. catalog.provider.update(providerID, (provider) => {
  26. provider.endpoint = {
  27. type: "aisdk",
  28. package: "@ai-sdk/openai-compatible",
  29. url: "https://default.example.com",
  30. }
  31. provider.options.aisdk.provider.baseURL = "https://override.example.com"
  32. }),
  33. )
  34. expect((yield* catalog.provider.get(providerID)).endpoint).toEqual({
  35. type: "aisdk",
  36. package: "@ai-sdk/openai-compatible",
  37. url: "https://override.example.com",
  38. })
  39. }),
  40. )
  41. it.effect("normalizes model baseURL into endpoint url", () =>
  42. Effect.gen(function* () {
  43. const catalog = yield* Catalog.Service
  44. const providerID = ProviderV2.ID.make("test")
  45. const modelID = ModelV2.ID.make("model")
  46. const load = yield* catalog.loader()
  47. yield* load((catalog) => {
  48. catalog.provider.update(providerID, (provider) => {
  49. provider.endpoint = {
  50. type: "aisdk",
  51. package: "@ai-sdk/openai-compatible",
  52. url: "https://provider.example.com",
  53. }
  54. })
  55. catalog.model.update(providerID, modelID, (model) => {
  56. model.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://model.example.com" }
  57. model.options.aisdk.provider.baseURL = "https://override.example.com"
  58. })
  59. })
  60. expect((yield* catalog.model.get(providerID, modelID)).endpoint).toEqual({
  61. type: "aisdk",
  62. package: "@ai-sdk/openai-compatible",
  63. url: "https://override.example.com",
  64. })
  65. }),
  66. )
  67. it.effect("resolves unknown model endpoint from provider endpoint", () =>
  68. Effect.gen(function* () {
  69. const catalog = yield* Catalog.Service
  70. const providerID = ProviderV2.ID.make("test")
  71. const modelID = ModelV2.ID.make("model")
  72. const load = yield* catalog.loader()
  73. yield* load((catalog) => {
  74. catalog.provider.update(providerID, (provider) => {
  75. provider.endpoint = {
  76. type: "aisdk",
  77. package: "@ai-sdk/openai-compatible",
  78. url: "https://provider.example.com",
  79. }
  80. })
  81. catalog.model.update(providerID, modelID, () => {})
  82. })
  83. expect((yield* catalog.model.get(providerID, modelID)).endpoint).toEqual({
  84. type: "aisdk",
  85. package: "@ai-sdk/openai-compatible",
  86. url: "https://provider.example.com",
  87. })
  88. }),
  89. )
  90. it.effect("runs catalog transform hooks after baseURL is normalized", () =>
  91. Effect.gen(function* () {
  92. const catalog = yield* Catalog.Service
  93. const plugin = yield* PluginV2.Service
  94. const providerID = ProviderV2.ID.make("test")
  95. const seen: unknown[] = []
  96. const load = yield* catalog.loader()
  97. yield* plugin.add({
  98. id: PluginV2.ID.make("test"),
  99. effect: Effect.succeed({
  100. "catalog.transform": (evt) =>
  101. Effect.sync(() => {
  102. const item = evt.data.find((record) => record.provider.id === providerID)
  103. if (!item) return
  104. seen.push(item.provider.endpoint.type)
  105. if (item?.provider.endpoint.type === "aisdk") seen.push(item.provider.endpoint.url)
  106. seen.push(item?.provider.options.aisdk.provider.baseURL)
  107. }),
  108. }),
  109. })
  110. yield* load((catalog) =>
  111. catalog.provider.update(providerID, (provider) => {
  112. provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
  113. provider.options.aisdk.provider.baseURL = "https://provider.example.com"
  114. }),
  115. )
  116. expect(seen).toEqual(["aisdk", "https://provider.example.com", undefined])
  117. }),
  118. )
  119. it.effect("runs catalog transform when a plugin is added", () =>
  120. Effect.gen(function* () {
  121. const catalog = yield* Catalog.Service
  122. const plugin = yield* PluginV2.Service
  123. const providerID = ProviderV2.ID.make("test")
  124. const load = yield* catalog.loader()
  125. yield* load((catalog) =>
  126. catalog.provider.update(providerID, (provider) => {
  127. provider.name = "Before"
  128. }),
  129. )
  130. yield* plugin.add({
  131. id: PluginV2.ID.make("test-transform"),
  132. effect: Effect.succeed({
  133. "catalog.transform": (evt) =>
  134. Effect.sync(() =>
  135. evt.provider.update(providerID, (provider) => {
  136. provider.name = "After"
  137. }),
  138. ),
  139. }),
  140. })
  141. yield* Effect.yieldNow
  142. expect((yield* catalog.provider.get(providerID)).name).toBe("After")
  143. }),
  144. )
  145. it.effect("resolves provider and model option merges", () =>
  146. Effect.gen(function* () {
  147. const catalog = yield* Catalog.Service
  148. const providerID = ProviderV2.ID.make("test")
  149. const modelID = ModelV2.ID.make("model")
  150. const load = yield* catalog.loader()
  151. yield* load((catalog) => {
  152. catalog.provider.update(providerID, (provider) => {
  153. provider.options.headers.provider = "provider"
  154. provider.options.headers.shared = "provider"
  155. provider.options.body.provider = true
  156. provider.options.aisdk.provider.provider = true
  157. })
  158. catalog.model.update(providerID, modelID, (model) => {
  159. model.options.headers.model = "model"
  160. model.options.headers.shared = "model"
  161. model.options.body.model = true
  162. model.options.aisdk.provider.model = true
  163. model.options.aisdk.request.request = true
  164. })
  165. })
  166. const model = yield* catalog.model.get(providerID, modelID)
  167. expect(model.options.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
  168. expect(model.options.body).toEqual({ provider: true, model: true })
  169. expect(model.options.aisdk.provider).toEqual({ provider: true, model: true })
  170. expect(model.options.aisdk.request).toEqual({ request: true })
  171. }),
  172. )
  173. it.effect("falls back to newest available model when no default is configured", () =>
  174. Effect.gen(function* () {
  175. const catalog = yield* Catalog.Service
  176. const providerID = ProviderV2.ID.make("test")
  177. const load = yield* catalog.loader()
  178. yield* load((catalog) => {
  179. catalog.provider.update(providerID, (provider) => {
  180. provider.enabled = { via: "custom", data: {} }
  181. })
  182. catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
  183. model.time.released = DateTime.makeUnsafe(1000)
  184. })
  185. catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => {
  186. model.time.released = DateTime.makeUnsafe(2000)
  187. })
  188. })
  189. expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toMatch("new")
  190. }),
  191. )
  192. it.effect("small model prefers small keyword candidates before cost scoring", () =>
  193. Effect.gen(function* () {
  194. const catalog = yield* Catalog.Service
  195. const providerID = ProviderV2.ID.make("test")
  196. const load = yield* catalog.loader()
  197. yield* load((catalog) => {
  198. catalog.provider.update(providerID, () => {})
  199. catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
  200. model.capabilities.input = ["text"]
  201. model.capabilities.output = ["text"]
  202. model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }]
  203. model.time.released = DateTime.makeUnsafe(Date.now())
  204. })
  205. catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
  206. model.capabilities.input = ["text"]
  207. model.capabilities.output = ["text"]
  208. model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }]
  209. model.time.released = DateTime.makeUnsafe(Date.now())
  210. })
  211. })
  212. expect(Option.getOrUndefined(yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
  213. }),
  214. )
  215. })