catalog.test.ts 8.4 KB

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